Five New Optimizely Certifications are Here! Validate your expertise and advance your career with our latest certification exams. Click here to find out more

Scott Reed
Nov 22, 2018
  37
(0 votes)

Working Around IQueryableNotificationUsers when using external claims based CMS users

We have implemented as per the guide here https://world.episerver.com/documentation/developer-guides/CMS/security/integrate-azure-ad-using-openid-connect/ claims based authentication hooking up with an AzureAD instance.

As part of this the standard user flow is that when a user logs in the following code is called

ServiceLocator.Current.GetInstance<ISynchronizingUserService>().SynchronizeAsync(ctx.AuthenticationTicket.Identity);

This creates a record in the [tblSynchedUser] table which allows parts of the CMS to select these users, however as myself and another developer has found the user selection was not working with parts of episerver such as Project Comments and Workflow when trying to selected a user.

https://world.episerver.com/forum/developer-forum/-Episerver-75-CMS/Thread-Container/2018/11/content-approvals-with-azure-ad/ 

https://world.episerver.com/forum/developer-forum/-Episerver-75-CMS/Thread-Container/2018/11/tag-user-in-project-comment-not-working-when-using-external-authentication-provider/

Issues

After digging around using DotPeek it turns out that there's an IQueryableNotificationUsers interface which is used by some of the rest stores when trying to query users, this interface is implemtned by 2 classes.

  • DefaultSynchronizedUsersRepository
  • AspNetIdentitySecurityEntityProvider

And after running my solution and getting an instance of IQueryableNotificationUsers it was returning back the AspNetIdentitySecurityEntityProvider which seems like a bug.

I could not find any documentation on how to set this from a config source and sadly DefaultSynchronizedUsersRepository happens to be an internal interface so this left me with one solution which has worked.

I have copied the code for this in to a class of my own

 /// <summary>
    /// Copy of internal DefaultSynchronizedUsersRepository to get around user selection issues
    /// </summary>
    /// <seealso cref="IQueryableNotificationUsers" />
    /// <seealso cref="IQueryablePreference" />
    /// <seealso cref="ISynchronizedUsersRepository" />
    public class CustomSynchronizedUsersRepository: IQueryableNotificationUsers, IQueryablePreference, ISynchronizedUsersRepository
    {
        private static ILogger _log = LogManager.GetLogger();
        private const string CacheKey = "GetRolesForUser_";
        private readonly ServiceAccessor<SynchronizeUsersDB> _syncronizeUsersDB;
        private readonly TaskExecutor _taskExecutor;
        private readonly IDatabaseMode _databaseModeService;
        private readonly ISynchronizedObjectInstanceCache _cache;
        private readonly ClaimTypeOptions _claimTypeOptions;
        private static IList<string> _synchronizedClaims;

        public CustomSynchronizedUsersRepository(ServiceAccessor<SynchronizeUsersDB> windowsProviderDb, TaskExecutor taskExecutor, IDatabaseMode databaseModeService, ISynchronizedObjectInstanceCache cache, ClaimTypeOptions claimTypeOptions)
        {
            this._syncronizeUsersDB = windowsProviderDb;
            this._taskExecutor = taskExecutor;
            this._databaseModeService = databaseModeService;
            this._cache = cache;
            this._claimTypeOptions = claimTypeOptions ?? new ClaimTypeOptions();
            CustomSynchronizedUsersRepository._synchronizedClaims = (IList<string>)new string[3]
            {
        this._claimTypeOptions.Email,
        this._claimTypeOptions.GivenName,
        this._claimTypeOptions.Surname
            };
        }

        public virtual Task SynchronizeAsync(string username, string roleClaimType, IEnumerable<Claim> claimsToSync)
        {
            return this._taskExecutor.Start((Action)(() => this.SynchronizeUserAndClaims(username, roleClaimType, claimsToSync)));
        }

        public virtual void Synchronize(string userName, string roleClaimType, IEnumerable<Claim> claimsToSync)
        {
            this.SynchronizeUserAndClaims(userName, roleClaimType, claimsToSync);
        }

        public virtual void ClearRoles(string userName)
        {
            this._syncronizeUsersDB().SynchronizeRoles(userName, Enumerable.Empty<string>());
            this.ClearUserCache(userName);
        }

        public virtual IEnumerable<string> GetRolesForUser(string userName)
        {
            EPiServer.Framework.Validator.ThrowIfNull(nameof(userName), (object)userName);
            string key = "GetRolesForUser_" + userName;
            string[] array = this._cache.Get(key) as string[];
            if (array == null)
            {
                array = this._syncronizeUsersDB().ListRolesForUser(userName).ToArray<string>();
                CacheEvictionPolicy evictionPolicy = new CacheEvictionPolicy(TimeSpan.FromMinutes(1.0), CacheTimeoutType.Absolute);
                this._cache.Insert(key, (object)array, evictionPolicy);
            }
            return (IEnumerable<string>)array;
        }

        public virtual IEnumerable<string> FindUsersInRole(string roleName, string partOfUsername)
        {
            return this._syncronizeUsersDB().FindUsersInRole(roleName, partOfUsername);
        }

        public virtual IEnumerable<string> FindUsers(string partOfName)
        {
            return this._syncronizeUsersDB().FindUsersByName(partOfName);
        }

        public virtual IEnumerable<string> FindRoles(string partOfName)
        {
            return this._syncronizeUsersDB().FindMatchingRoles(partOfName);
        }

        public virtual IEnumerable<SynchronizedRoleStatus> ListRoleStatus()
        {
            return this._syncronizeUsersDB().ListRoleStatus();
        }

        public virtual void HideRole(string role)
        {
            EPiServer.Framework.Validator.ThrowIfNullOrEmpty(nameof(role), role);
            this._syncronizeUsersDB().SetVisibilityForRole(false, role);
        }

        public virtual void ShowRole(string role)
        {
            EPiServer.Framework.Validator.ThrowIfNullOrEmpty(nameof(role), role);
            this._syncronizeUsersDB().SetVisibilityForRole(true, role);
        }

        public IEnumerable<string> DefaultSynchronizedClaims
        {
            get
            {
                return (IEnumerable<string>)CustomSynchronizedUsersRepository._synchronizedClaims;
            }
        }

        private void ClearUserCache(string userName)
        {
            this._cache.Remove("GetRolesForUser_" + userName);
        }

        private List<string> GetRolesFromClaims(string roleClaimType, IEnumerable<Claim> claims)
        {
            return claims.Where<Claim>((Func<Claim, bool>)(c => c.Type.Equals(roleClaimType, StringComparison.OrdinalIgnoreCase))).Where<Claim>((Func<Claim, bool>)(c => !string.IsNullOrEmpty(c.Value))).Select<Claim, string>((Func<Claim, string>)(c => c.Value)).Distinct<string>((IEqualityComparer<string>)StringComparer.OrdinalIgnoreCase).ToList<string>();
        }

        private static NameValueCollection GetAdditionalClaims(IEnumerable<Claim> claims)
        {
            NameValueCollection nameValueCollection = new NameValueCollection();
            foreach (Claim claim in claims.Where<Claim>((Func<Claim, bool>)(c =>
            {
                if (!CustomSynchronizedUsersRepository._synchronizedClaims.Contains<string>(c.Type, (IEqualityComparer<string>)StringComparer.OrdinalIgnoreCase))
                    return !string.IsNullOrEmpty(c.Value);
                return false;
            })))
                nameValueCollection.Add(claim.Type, claim.Value);
            return nameValueCollection;
        }

        internal string GetEmailFromClaims(IEnumerable<Claim> claims)
        {
            return claims.Where<Claim>((Func<Claim, bool>)(c => c.Type.Equals(this._claimTypeOptions.Email, StringComparison.Ordinal))).Select<Claim, string>((Func<Claim, string>)(c => c.Value)).FirstOrDefault<string>();
        }

        internal string GetGivenNameFromClaims(IEnumerable<Claim> claims)
        {
            return claims.Where<Claim>((Func<Claim, bool>)(c => c.Type.Equals(this._claimTypeOptions.GivenName, StringComparison.OrdinalIgnoreCase))).Select<Claim, string>((Func<Claim, string>)(c => c.Value)).FirstOrDefault<string>();
        }

        internal string GetSurnameFromClaims(IEnumerable<Claim> claims)
        {
            return claims.Where<Claim>((Func<Claim, bool>)(c => c.Type.Equals(this._claimTypeOptions.Surname, StringComparison.OrdinalIgnoreCase))).Select<Claim, string>((Func<Claim, string>)(c => c.Value)).FirstOrDefault<string>();
        }

        Task<PagedNotificationUserResult> IQueryableNotificationUsers.FindAsync(string partOfUser, int pageIndex, int pageSize)
        {
            return this._syncronizeUsersDB().FindUsersAsync(partOfUser, pageIndex, pageSize);
        }

        internal virtual void SynchronizeUserAndClaims(string userName, string roleClaimType, IEnumerable<Claim> claims)
        {
            EPiServer.Framework.Validator.ThrowIfNull(nameof(userName), (object)userName);
            this.RequiredReadWriteDatabaseMode((Action)(() =>
            {
                string emailFromClaims = this.GetEmailFromClaims(claims);
                string givenNameFromClaims = this.GetGivenNameFromClaims(claims);
                string surnameFromClaims = this.GetSurnameFromClaims(claims);
                NameValueCollection additionalClaims = CustomSynchronizedUsersRepository.GetAdditionalClaims(claims);
                if (!string.IsNullOrEmpty(givenNameFromClaims) || !string.IsNullOrEmpty(surnameFromClaims) || (!string.IsNullOrEmpty(emailFromClaims) || additionalClaims.Count > 0))
                    this._syncronizeUsersDB().SynchronizeUser(userName, givenNameFromClaims, surnameFromClaims, emailFromClaims, additionalClaims);
                else if (!this._syncronizeUsersDB().FindUsersByName(userName).Any<string>((Func<string, bool>)(u => string.Equals(userName, u, StringComparison.OrdinalIgnoreCase))))
                    this._syncronizeUsersDB().SynchronizeUser(userName, (string)null, (string)null, (string)null, (NameValueCollection)null);
                this._syncronizeUsersDB().SynchronizeRoles(userName, (IEnumerable<string>)this.GetRolesFromClaims(roleClaimType, claims));
            }));
            this.ClearUserCache(userName);
        }

        private void RequiredReadWriteDatabaseMode(Action action)
        {
            if (this._databaseModeService.DatabaseMode == DatabaseMode.ReadWrite)
            {
                action();
            }
            else
            {
                ILogger log = CustomSynchronizedUsersRepository._log;
                string messageFormat = "The action '{0}' has not been called becuase the database is in the ReadOnly mode.";
                object[] objArray = new object[1];
                int index = 0;
                string str;
                if (action == null)
                {
                    str = (string)null;
                }
                else
                {
                    MethodInfo method = action.Method;
                    str = (object)method != null ? method.Name : (string)null;
                }
                objArray[index] = (object)str;
                log.Debug(messageFormat, objArray);
            }
        }

        int IQueryablePreference.SortOrder
        {
            get
            {
                return 20;
            }
        }

        string IQueryablePreference.GetPreference(string userName, string preferenceName)
        {
            string str = (string)null;
            NameValueCollection nameValueCollection = this._syncronizeUsersDB().LoadMetadata(userName);
            if (nameValueCollection != null)
                str = nameValueCollection.GetValues(preferenceName)?[0];
            return str;
        }
    }

And have registered this class using the standard dependencie framework

            container.Services.AddTransient<IQueryableNotificationUsers, CustomSynchronizedUsersRepository>();
            container.Services.AddTransient<IQueryablePreference, CustomSynchronizedUsersRepository>();
            container.Services.AddTransient<ISynchronizedUsersRepository, CustomSynchronizedUsersRepository>();

Until this issue is fixed or I find a way to set this in configuration I will continue to use this, but I'll pop it as a bug and ideally hopefully they will make this class public so be can control this in our DI code.

Thanks.

Nov 22, 2018

Comments

Please login to comment.
Latest blogs
Optimizely Configured Commerce and Spire CMS - Figuring out Handlers

I recently entered the world of Optimizely Configured Commerce and Spire CMS. Intriguing, interesting and challenging at the same time, especially...

Ritu Madan | Mar 12, 2025

Another console app for calling the Optimizely CMS REST API

Introducing a Spectre.Console.Cli app for exploring an Optimizely SaaS CMS instance and to source code control definitions.

Johan Kronberg | Mar 11, 2025 |

Extending UrlResolver to Generate Lowercase Links in Optimizely CMS 12

When working with Optimizely CMS 12, URL consistency is crucial for SEO and usability. By default, Optimizely does not enforce lowercase URLs, whic...

Santiago Morla | Mar 7, 2025 |

Optimizing Experiences with Optimizely: Custom Audience Criteria for Mobile Visitors

In today’s mobile-first world, delivering personalized experiences to visitors using mobile devices is crucial for maximizing engagement and...

Nenad Nicevski | Mar 5, 2025 |

Unable to view Optimizely Forms submissions when some values are too long

I discovered a form where the form submissions could not be viewed in the Optimizely UI, only downloaded. Learn how to fix the issue.

Tomas Hensrud Gulla | Mar 4, 2025 |

CMS 12 DXP Migrations - Time Zones

When it comes to migrating a project from CMS 11 and .NET Framework on the DXP to CMS 12 and .NET Core one thing you need to be aware of is the...

Scott Reed | Mar 4, 2025