In my former post I’ve illustrated how to synchronize user images between AD and SharePoint using a custom web part in a self-service way. If you need to synchronize user images automatically you need a more advanced technique.
The user profile synchronization service in SharePoint provides a built-in solution for you, but only if you need one-way (SharePoint to AD) synchronization of images, as described in this article:
Replicating user pictures from Sharepoint 2010 to Exchange 2010 and Communications Server 14
If you would like the user images to be updated automatically in AD and SharePoint when it is changed on either end of the connection you need to apply some tricks.
(REMARK: Most of the codes for this solution are borrowed from my former post, hence I’ve include only the most relevant new snippets in the current post. The entire solution can be downloaded from here, use it at your own risk. Any feedback is welcome.)
To fulfill the requirement I’ve created a custom timer job that runs periodically and checks the images for all users in both AD and SharePoint, and if these images are different (missing on either sides or has different hash value) then the the more recent image is copied over the older one into the other system.
(REMARK: In this post I do not concentrate on the custom timer job development, just on the actual synchronization task. If you are new to timer jobs, you can find several excellent posts about this topic on the web, like this one from Andrew Connell:
Creating Custom SharePoint Timer Jobs)
But the question is: how to get the date when the image is modified in the AD and in the SharePoint profile?
It was lot easier (at least, for me) to answer the second one: the images stored in a dedicated list in the SharePoint My Site Host, so we can read the last modified date using standard SharePoint API.
After some investigation in AD and on the web I’ve found the answer to the first part as well. The LastOriginatingChangeTime property of the AttributeMetadata class (that can be accesses through the ActiveDirectoryReplicationMetadata object) gives you information about when a specific AD property was changed.
The following code snippet from the helper class ADUtils illustrates the concept:
- /// <summary>
- /// Gets the time of last user photo change in AD
- /// </summary>
- public DateTime UserPhotoLastModified
- {
- get
- {
- // if we do not find info about the property change we consider it is changed "never"
- DateTime lastModified = DateTime.MinValue;
- ActiveDirectoryReplicationMetadata rm = _domainController.GetReplicationMetadata(_userPath);
- AttributeMetadata am = rm["thumbnailPhoto"];
- if (am != null)
- {
- lastModified = am.LastOriginatingChangeTime;
- }
- return lastModified;
- }
- }
The ADUtils class is initialized like this:
- String _defaultPartition;
- DirectoryContext _directoryContext;
- DomainController _domainController;
- DirectoryEntry _user;
- String _userPath;
- public ADUtils(String userName)
- {
- String[] splittedName = userName.Split(new char[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
- String domainShortName = splittedName[0];
- String accountName = splittedName[1];
- _directoryContext = new DirectoryContext(DirectoryContextType.Domain, domainShortName);
- _domainController = DomainController.FindOne(_directoryContext);
- _defaultPartition = GetDomainRoot(domainShortName);
- _user = FindUserByAccountName(accountName);
- _userPath = _user.Path.Remove(0, 7); //remove the LDAP prefix
- }
If you read my former post in the topic you might remember that there was a web part property that stored the AD root partition. In the meantime I found a direct way to get this AD path from the short domain name:
- /// <summary>
- /// Retrieves the path of root AD partition
- /// </summary>
- /// <param name="domainShortName"></param>
- /// <returns></returns>
- private String GetDomainRoot(String domainShortName)
- {
- Domain domain = Domain.GetDomain(_directoryContext);
- String domainName = domain.Name;
- String domainComponent = "DC=" + domainName.Replace(".", ",DC=");
- return domainComponent;
- }
We can access the AD photo via the ThumbnailPhoto property of the ADUtils class:
- /// <summary>
- /// Gets / sets the thumbnail photo for the user
- /// </summary>
- public byte[] ThumbnailPhoto
- {
- get
- {
- // if we do not find info about the thumbnailPhoto property we consider it is "empty"
- byte[] thumbnailPhotoBytes = null;
- if (_user.Properties.Contains("thumbnailPhoto"))
- {
- thumbnailPhotoBytes = (byte[])_user.Properties["thumbnailPhoto"].Value;
- }
- return thumbnailPhotoBytes;
- }
- set
- {
- _user.Properties["thumbnailPhoto"].Clear();
- if (value != null)
- {
- _user.Properties["thumbnailPhoto"].Add(value);
- }
- _user.CommitChanges();
- }
- }
I’ve created a static helper method to access profile properties easier:
- public static class Extensions
- {
- public static String GetProfileValue(this UserProfile profile, String propertyName)
- {
- String propertyValue = String.Empty;
- ProfileValueCollectionBase propertyValues = profile.GetProfileValueCollection(propertyName);
- if ((propertyValues != null) && (propertyValues.Count != 0))
- {
- propertyValue = (String)propertyValues[0];
- }
- return propertyValue;
- }
- }
After this short introduction to the code environment, let’s see the code that is responsible for the synchronization logic. In the SynchronizePhotos method we iterate through all users in the profile manager, check the user images both in AD and in the SharePoint profile, and act if the images are different:
- public void SynchronizePhotos()
- {
- using (SPSite site = new SPSite(_mySiteHostUrl))
- {
- using (SPWeb web = site.OpenWeb())
- {
- foreach (UserProfile userProfile in _profileManager)
- {
- // using our custom extension method
- String accountName = userProfile.GetProfileValue("AccountName");
- Trace.TraceInformation("SynchronizePhotos: Processing user: '{0}'", accountName);
- ADUtils au = new ADUtils(accountName);
- DateTime adPhotoLastModified = au.UserPhotoLastModified;
- // assigning a dummy value just to initialize the variable, will be updated later
- DateTime spPhotoLastModified = DateTime.MinValue;
- byte[] sharePointImageContent = null;
- byte[] activeDirectoryImageContent = au.ThumbnailPhoto;
- String pictureUrl = userProfile.GetProfileValue("PictureUrl");
- // the picture URL must refer to the image stored on My Site Host
- if (!String.IsNullOrEmpty(pictureUrl))
- {
- SPFile file = web.GetFile(pictureUrl);
- if (file.Exists)
- {
- Trace.TraceInformation("SP profile photo found");
- SPListItem listItem = file.GetListItem(new String[] { "Modified" });
- spPhotoLastModified = (DateTime)listItem["Modified"];
- sharePointImageContent = file.OpenBinary();
- }
- }
- bool hasADPhoto = (activeDirectoryImageContent != null);
- bool hasSPProfilePhoto = (sharePointImageContent != null);
- if ((hasSPProfilePhoto) && (hasADPhoto))
- {
- Trace.TraceInformation("Both AD and SP profile photos are specified, checking rules for update");
- byte[] spHash = new MD5CryptoServiceProvider().ComputeHash(sharePointImageContent);
- byte[] adHash = new MD5CryptoServiceProvider().ComputeHash(activeDirectoryImageContent);
- bool sameImage = (Convert.ToBase64String(spHash) == Convert.ToBase64String(adHash));
- // if different, try to synchronize based on last modified date in AD vs. SP
- if (sameImage)
- {
- Trace.TraceInformation("AD and SP profile photos are the same, no further synchronization action required");
- }
- else
- {
- Trace.TraceInformation("AD photo last set: '{0}', SP profile photo last set: '{1}'", adPhotoLastModified, spPhotoLastModified);
- if (spPhotoLastModified < adPhotoLastModified)
- {
- UpdatePhotoInSharePoint(activeDirectoryImageContent, accountName, web);
- }
- else
- {
- UpdatePhotoInActiveDirectory(sharePointImageContent, au);
- }
- }
- }
- else if (hasSPProfilePhoto)
- {
- Trace.TraceInformation("User has SP profile photo but no AD photo");
- if (spPhotoLastModified < adPhotoLastModified)
- {
- Trace.TraceInformation("SP photo is older than AD last modified (deleted), delete SP photo");
- DeletePhotoInSharePoint(accountName, web);
- }
- else
- {
- Trace.TraceInformation("SP photo is newer than AD last modified, copy SP photo to AD");
- UpdatePhotoInActiveDirectory(sharePointImageContent, au);
- }
- }
- else if (hasADPhoto)
- {
- UpdatePhotoInSharePoint(activeDirectoryImageContent, accountName, web);
- }
- else
- {
- Trace.TraceInformation("There is no AD or SP profile photo for user");
- }
- Trace.TraceInformation("SynchronizePhotos processing user: '{0}' finished", accountName);
- }
- }
- }
- }
Important notes:
Before trying out the job, be sure that the SharePoint 2010 Timer service (SPTimerV4, OWSTIMER.EXE) is running, and the service account has write rights to alter Active Directory properties. For the sake of simplicity my service account has domain admin rights, but if you would like to set the permissions as recommended I suggest you to read this post, especially the section Nice, so what about actually writing back to AD (Sync)?:
Rational Guide to implementing SharePoint Server 2010 User Profile Synchronization
If you change the code it is important to restart the SharePoint 2010 Timer otherwise the new assembly won’t be loaded by the service.
The next image shows the timer job after deployment on the list of scheduled jobs:
If you click on the name of the job, the details are displayed. If you don’t want to wait for the job to be started automatically, you can click Run Now.
By clicking Run Now you will be navigated back to the list of scheduled jobs. If you would like to run the job once more but would not like to look up the job in the long list, simply click Back in Internet Explorer or use the Alt + Left Arrow key combination. After you re-deploy the solution you need to refresh the page in IE (use F5).
The code that reads and writes (copies to be exact) user images in SharePoint and AD are the same (or at least, very similar) to the previous version you can find in my former post.
As you can see, I’ve included plenty of trace commands to make it easier to track what happens during timer job installation and execution, for example by using DebugView. If you would like to follow the execution step-by-step, you can debug the timer job. Don’t forget to attach the debugger to OWSTIMER.EXE. You may find my Visual Studio extensions useful in this case.
You can test the results and modify the image for a user in SharePoint using the Manage User Profiles admin page on the Central Administration site.
If you do any changes in the profile, do not forget to click Save and Close on the top of the page to persist your changes.
To do the same in Active Directory I suggest you to install this extension that enables Active Directory Users and Computers to manage the thumbnailPhoto property a user friendly way:
Again, if you change the thumbnail, press Apply or OK.
Known issues, limitations:
If you have read the code thoughtfully, you might have already noticed that it does handle a special case the wrong way. If the same image exists both is SharePoint and AD, but deleted later in SharePoint, one can expect that the corresponding image will be deleted in the AD as well. Instead of this, the image from the AD will be copied back to SharePoint again.
The reason of this behavior is that there is no information in SharePoint (unless one would like to include audit data into the game) about when the images were deleted or when the PictureUrl property was set last time in the profile. In lack of this information we can not decide which of the systems is the current one, and we choose the action that does not cause data lost, copy the AD image to SharePoint, instead of deleting the AD image as well.
If we really need this functionality, we can replace the timer job with an event receiver that synchronize (update or delete) the corresponding AD image immediately when the SharePoint image has been changed or deleted.
This type of working would be complete by using AD change notification mechanism on the other side, something that described here:
Change Notifications in Active Directory Domain Services
Since these methods do not fit well into the timer job that is the topic of the current post, I ignore them for now. Maybe in a later post…
Thanks for the post. Worked Perfectly for me and saved me a lot of time.
Comment by Vicky — April 27, 2011 @ 20:07
Can you please tell me the step to install the timer. Thank you.
Comment by Seong — June 19, 2012 @ 12:26