Starts in:
1 DAY
23 HRS
5 MIN
20 SEC
Starts in:
1 D
23 H
5 M
20 S
Open Closed

Duplicate CmsUser insertion #3779


User avatar
0
cbogner85 created
  • ABP Framework version: v6.0.0
  • UI type: MVC
  • DB provider: EF Core
  • Tiered (MVC) or Identity Server Separated (Angular): no

Hello,

as my users always complained about tenant-specific URLs, I decided to develop a unified login. Therefore, I extended all required methods such as LoginModel, ForgotPasswordModel, ResetPasswordModel and so on to work without multi-tenancy.

For the LoginModel, I disable multi-tenancy filter, get the tenant ID by the inserted user name (which is unique for all tenants), change CurrentTenant to the user's tenant and then perform PasswordSignIn.

Everything works almost as expected, but I got a strange issue: in some very rare cases, when users try to login from the unified login page, they receive an error 500, because a duplicate CmsUser insertion is tried. I can see from the sql server logs that it tries to find the cms user with tenant null:

exec sp_executesql N'SELECT TOP(1) [c].[Id], [c].[ConcurrencyStamp], [c].[Email], [c].[EmailConfirmed], [c].[ExtraProperties], [c].[IsActive], [c].[Name], [c].[PhoneNumber], [c].[PhoneNumberConfirmed], [c].[Surname], [c].[TenantId], [c].[UserName]
FROM [CmsUsers] AS [c]
WHERE ((@__ef_filter__p_0 = CAST(1 AS bit)) OR ([c].[TenantId] = @__ef_filter__CurrentTenantId_1)) AND ([c].[Id] = @__id_0)
ORDER BY [c].[Id]',N'@__ef_filter__p_0 bit,@__ef_filter__CurrentTenantId_1 uniqueidentifier,@__id_0 uniqueidentifier',@__ef_filter__p_0=0,@__ef_filter__CurrentTenantId_1=null,@__id_0='[userid]'

Important part: it searches for __CurrentTenantId_1=null although I changed the tenant.

Of course this doesn't return a user (as the user is tenant user and not host user) and therefore it tries to insert a new CmsUser, (interestingly with the correct switched tenantid!). This if course produces an error because of the insertion of a duplicate Id.

I don't understand why this happens only in rare cases and not always. But I want to figure out and therefore, I need to know where this CmsUser Inserting during login process takes place. I couldn't find it from the sources. Maybe you could point me to the right direction to help me fix it.

Thanks in advance!


6 Answer(s)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    login from the unified login page,

    Can you share your login code?

  • User Avatar
    0
    cbogner85 created

    Hi @maliming,

    sure, this is my LoginModel extension:

      [Dependency(ReplaceServices = true)]
        [ExposeServices(typeof(AccountPageModel), typeof(LoginModel))]
        public class CustomLoginModel : LoginModel
        {
            private readonly IDataFilter _dataFilter;
            private readonly ICurrentTenant _currentTenant;
            private readonly IExtendedOrganizationUnitAppService _extendedOrganizationUnitAppService;
            public CustomLoginModel(IAuthenticationSchemeProvider schemeProvider,
                IOptions<AbpAccountOptions> accountOptions,
                IAbpRecaptchaValidatorFactory recaptchaValidatorFactory,
                IAccountExternalProviderAppService accountExternalProviderAppService,
                ICurrentPrincipalAccessor currentPrincipalAccessor,
                IOptions<IdentityOptions> identityOptions,
                IOptionsSnapshot<reCAPTCHAOptions> reCaptchaOptions,
                ICurrentTenant currentTenant,
                IExtendedOrganizationUnitAppService extendedOrganizationUnitAppService,
                IDataFilter dataFilter) : base(schemeProvider, accountOptions, recaptchaValidatorFactory, accountExternalProviderAppService, currentPrincipalAccessor, identityOptions, reCaptchaOptions)
            {
                _dataFilter = dataFilter;
                _currentTenant = currentTenant;
                _extendedOrganizationUnitAppService = extendedOrganizationUnitAppService;
            }
    
            public override async Task<IActionResult> OnPostAsync(string action)
            {
    
                try
                {
                    await ReCaptchaVerification();
                }
                catch (UserFriendlyException e)
                {
                    if (e is ScoreBelowThresholdException)
                    {
                        var onScoreBelowThresholdResult = OnRecaptchaScoreBelowThreshold();
                        if (onScoreBelowThresholdResult != null)
                        {
                            return await onScoreBelowThresholdResult;
                        }
                    }
    
                    Alerts.Danger(GetLocalizeExceptionMessage(e));
                    return Page();
                }
    
                ValidateModel();
    
                await IdentityOptions.SetAsync();
    
                var localLoginResult = await CheckLocalLoginAsync();
                if (localLoginResult != null)
                {
                    return localLoginResult;
                }
    
                IsSelfRegistrationEnabled = await SettingProvider.IsTrueAsync(AccountSettingNames.IsSelfRegistrationEnabled);
    
                Volo.Abp.Identity.IdentityUser user;
    
                Guid? tenantId;
    
                // disable TenantFilter
                using (_dataFilter.Disable<IMultiTenant>())
                {
                    // read tenant from username (unique for all tenants, by overwritten RegisterModel)
                    // if we login with disabled IMultiTenant DataFilter, we have issues with claims
                    // therefore we read tenant from username, then switch the current tenant and login
    
                    await ReplaceEmailToUsernameOfInputIfNeeds();
                    tenantId = await _extendedOrganizationUnitAppService.GetTenantByUsername(LoginInput.UserNameOrEmailAddress);
                }
                
                // change the current tenant
                using (CurrentTenant.Change(tenantId))
                {
    
                    IsLinkLogin = await VerifyLinkTokenAsync();
    
    
                    var result = await SignInManager.PasswordSignInAsync(
                    LoginInput.UserNameOrEmailAddress,
                    LoginInput.Password,
                    LoginInput.RememberMe,
                    true
                );
    
                    await IdentitySecurityLogManager.SaveAsync(new IdentitySecurityLogContext
                    {
                        Identity = IdentitySecurityLogIdentityConsts.Identity,
                        Action = result.ToIdentitySecurityLogAction(),
                        UserName = LoginInput.UserNameOrEmailAddress
                    });
    
                    if (result.RequiresTwoFactor)
                    {
    
                        return RedirectToPage("./SendSecurityCode", new
                        {
                            returnUrl = base.ReturnUrl,
                            returnUrlHash = base.ReturnUrlHash,
                            rememberMe = LoginInput.RememberMe,
                            linkUserId = base.LinkUserId,
                            linkTenantId = base.LinkTenantId,
                            linkToken = base.LinkToken
                        });
                    }
    
                    if (result.IsLockedOut)
                    {
                        return RedirectToPage("./LockedOut", new
                        {
                            returnUrl = ReturnUrl,
                            returnUrlHash = ReturnUrlHash
                        });
                    }
    
                    if (result.IsNotAllowed)
                    {
                        var notAllowedUser = await GetIdentityUser(LoginInput.UserNameOrEmailAddress);
                        if (notAllowedUser.IsActive && await UserManager.CheckPasswordAsync(notAllowedUser, LoginInput.Password))
                        {
                            await StoreConfirmUser(notAllowedUser);
                            return RedirectToPage("./ConfirmUser", new
                            {
                                returnUrl = ReturnUrl,
                                returnUrlHash = ReturnUrlHash
                            });
                        }
    
                        Alerts.Danger(L["LoginIsNotAllowed"]);
                        return Page();
                    }
    
                    if (!result.Succeeded)
                    {
                        Alerts.Danger(L["InvalidUserNameOrPassword"]);
                        return Page();
                    }
    
                    user = await GetIdentityUser(LoginInput.UserNameOrEmailAddress);
                }
    
                if (IsLinkLogin)
                {
                    using (CurrentPrincipalAccessor.Change(await SignInManager.CreateUserPrincipalAsync(user)))
                    {
                        await IdentityLinkUserAppService.LinkAsync(new LinkUserInput
                        {
                            UserId = LinkUserId.Value,
                            TenantId = LinkTenantId,
                            Token = LinkToken
                        });
    
                        await IdentitySecurityLogManager.SaveAsync(new IdentitySecurityLogContext
                        {
                            Identity = IdentitySecurityLogIdentityConsts.Identity,
                            Action = IdentityProSecurityLogActionConsts.LinkUser,
                            UserName = user.UserName,
                            ExtraProperties =
                            {
                                { IdentityProSecurityLogActionConsts.LinkTargetTenantId, LinkTenantId },
                                { IdentityProSecurityLogActionConsts.LinkTargetUserId, LinkUserId }
                            }
                        });
    
                        using (CurrentTenant.Change(LinkTenantId))
                        {
                            var targetUser = await UserManager.GetByIdAsync(LinkUserId.Value);
                            using (CurrentPrincipalAccessor.Change(await SignInManager.CreateUserPrincipalAsync(targetUser)))
                            {
                                await IdentitySecurityLogManager.SaveAsync(new IdentitySecurityLogContext
                                {
                                    Identity = IdentitySecurityLogIdentityConsts.Identity,
                                    Action = IdentityProSecurityLogActionConsts.LinkUser,
                                    UserName = targetUser.UserName,
                                    ExtraProperties =
                                    {
                                        { IdentityProSecurityLogActionConsts.LinkTargetTenantId, targetUser.TenantId },
                                        { IdentityProSecurityLogActionConsts.LinkTargetUserId, targetUser.Id }
                                    }
                                });
                            }
                        }
    
                        return RedirectToPage("./LinkLogged", new
                        {
                            returnUrl = ReturnUrl,
                            returnUrlHash = ReturnUrlHash,
                            TargetLinkUserId = LinkUserId,
                            TargetLinkTenantId = LinkTenantId
                        });
                    }
                }
    
                return RedirectSafely(ReturnUrl, ReturnUrlHash);
            }
    
    
        }
    
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    I think you can change to Host instead of disabling the filter.

    using (CurrentTenant.Change(null))

    
    // disable TenantFilter
    using (_dataFilter.Disable())
    {
        // read tenant from username (unique for all tenants, by overwritten RegisterModel)
        // if we login with disabled IMultiTenant DataFilter, we have issues with claims
        // therefore we read tenant from username, then switch the current tenant and login
    
        await ReplaceEmailToUsernameOfInputIfNeeds();
        tenantId = await _extendedOrganizationUnitAppService.GetTenantByUsername(LoginInput.UserNameOrEmailAddress);
    }
    

    btw, can you share tenantId = await _extendedOrganizationUnitAppService.GetTenantByUsername(LoginInput.UserNameOrEmailAddress); as well?

  • User Avatar
    0
    cbogner85 created

    hi @maliming,

    I can't change to the host, as it would only find host users. I don't want to disable multi-tenancy, I absolutely need all items to be multi-tenant. I only want to let the users login from a unified login page without having to select the tenant from tenantbox or having different subdomains for each tenant.

    the function of tenantId = await _extendedOrganizationUnitAppService.GetTenantByUsername(LoginInput.UserNameOrEmailAddress); is quite simple:

      public async Task<Guid?> GetTenantByUsername(string userName)
      {
                var user = await _identityUserRepository.FindByNormalizedUserNameAsync(userName.ToUpper());
                if (user != null)
                {
                    return user.TenantId;
                }
                else
                {
                    return null;
                }
                
    }
    

    Every few days one I got the issue again after a couple of days without the issue. It's really strange. A user can't login from the unified login page, because a duplicate Id insertion into table CmsUsers is tried and the result is an error 500. When I change his password and try to login, I can reproduce the issue. When I login for one time using the tenant specific URL (with subdomain), it works and after that, the unified login works again, too. Therefore I think that the tenant (null, from the unified page) is stored somewhere and is used during CmsUser search, although I changed it in my custom login method... of course I tried in private mode of the browser to ensure the tenant isn't stored somewhere in the cookies.

    Where does CmsUser search/ insert during login process takes place? Could you point me to the source file? Maybe overriding it to customize it to my needs is easier than finding the cause of that issue.

    Thanks

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Where does CmsUser search/ insert during login process takes place? Could you point me to the source file? Maybe overriding it to customize it to my needs is easier than finding the cause of that issue.

    hi

    CreateUser

    https://github.com/abpframework/abp/blob/dev/modules/cms-kit/src/Volo.CmsKit.Domain/Volo/CmsKit/Users/CmsUserLookupService.cs

    https://github.com/abpframework/abp/blob/dev/modules/users/src/Volo.Abp.Users.Domain/Volo/Abp/Users/UserLookupService.cs

  • User Avatar
    0
    cbogner85 created

    Hi @maliming!

    Thank you for pointing me to the right direction.

    I found out that the issue occurs in UserLookupService > FindByUserId. It seems that sometimes the tenant filter is not working correctly, although I changed the tenant.

    I worked around by disabling multi-tenancy filter for the user lookup:

           // Fix CmsUser Insert
            TUser localUser;
    
            using (_dataFilter.Disable<IMultiTenant>())
            {
                localUser = await _userRepository.FindAsync(id, cancellationToken: cancellationToken);
            }
              
    

    Seems a bit quick and dirty, but on the other hand, the id is unique anyway, so I don't see a problem doing the search globally. Since that change the issue is gone.

    Therefore I'll close this issue.

Made with ❤️ on ABP v9.1.0-preview. Updated on November 20, 2024, 13:06