Arquitectura, buenas prácticas y desarrollo sobre la nueva herramienta de Microsoft SharePoint 2016

SharePoint CSOM ya disponible en .NET Standard

A finales de junio, se publicó una versión estable y oficial de uno de los grandes requerimientos de los desarrolladores de Office en los últimos años: CSOM para .NET Standard, o lo que es lo mismo, la librería Cliente para poder acceder a SharePoint. En este artículo vamos a explicar los motivos de esta demanda y veremos también cómo empezar a usarlo con un sencillo ejemplo 🙂

Historia

En estos momentos, hablar de la importancia que tiene que una librería en una determinada tecnología, visto desde la perspectiva de cualquier tecnólogo, puede parecer un sinsentido si no se tiene el contexto, ¿y cuál es el contexto? La forma en la que desarrolla en los productos Microsoft ha evolucionado mucho en los últimos años.

Microsoft dispone de muchos productos que se utilizan en muchísimos clientes como son SharePoint, Dynamics CRM, … Estos productos, hace no muchos años, se instalaban en la infraestructura de los clientes y el desarrollo se realizaba instalando “artefactos” en dicho servidor. Ahora bien, los tiempos cambian y estos productos ahora son servicios que consumimos en el Cloud, y naturalmente no tenemos acceso al servidor para instalar nada.

Esto implicó un gran cambio en la forma de desarrollar sobre SharePoint. Pasamos de un desarrollo en lenguajes de servidor como C# a especialistas en el FrontEnd. La cuestión no es solo un cambio de tecnología (cambiar C# por Typescript con el frameworks que más rabia nos dé), sino que muchas de las acciones que se hacían en los desarrollos, ahora no se pueden hacer.

Acciones como poder impersonar usuarios al estar en el Front. No se pueden hacer procesos “programados”/“bajo demanda” para integración con otros sistemas (y aunque se pudiera, no sería una opción a plantear). Para poder hacer esta funcionalidad teníamos  que utilizar CSOM( Client SharePoint Object Model) o bien directamente llamadas a la API Rest de SharePoint. La problemática en este punto, es que la API Rest de SharePoint no tiene todos los endpoint ni toda la funcionalidad que hay en CSOM y además, CSOM es mucho más fácil de utilizar. En este punto, CSOM fue relativamente importante, tanto para añadirlo en API Rest Custom como en el uso de Azure Functions. En las primeras versiones, cuando Azure Functions en su versión 1.0 era .NET Framework y .NET Core estaba en sus primeras versiones (con todas las dudas sobre su evolución y el camino que iba a seguir), un desarrollador de Office podía extender su desarrollo de una forma natural. Conforme aumentaron las versiones de dichos productos y su implantación fue mayor,  poder usar una Azure Function en el contexto de SharePoint se convirtió en algo relativamente complejo. Muchos en este punto pensaréis qué necesidades puede tener  hacer uso de SharePoint en una Azure Functions. Imaginad que tengo unos documentos en SharePoint y quiero mandarlos a un almacenamiento más ligero para tenerlo en un histório como Blob Storage. Para hacer este desarrollo, antaño teníamos dos opciones:

  • Hacer uso de Azure Functions en versión 1.0 (con sus contras: obsoleto, tuning del toolings de herramientas)
  • Hacer uso de Azure Functions en versión 2.0 o superior (tened en cuenta que la comunicación con todo lo relacionado con SharePoint se tendría que poner una “pieza”(API Rest) implementada en .NET Framework).

Como podéis ver, cualquiera de estas opciones no es la mejor. Independientemente de la que escojamos, siempre elegimos la menos mala. Ahora bien, con la llegada de CSOM en .NET Standard todos estos impedimentos desaparecen. Ahora tenemos otras cosas, y las primeras preguntas que surgen son ¿CSOM en .NET Framework y Standard tienen las mismas opciones? ¿Son iguales?

¿Qué cosas podemos hacer con CSOM en .NET Core y cuáles son sus limitaciones?

Lo primero que indica la documentación es que esta versión de CSOM solo es compatible con la versión Online de SharePoint. En las versiones OnPremise no está actualizada ni soportada.

El principal cambio del uso de esta librería es que no soporta ningún método que tenga la autenticación SharePointOnlineCredentials, el uso de la autenticación basada en usuario/contraseña es un método común para los programadores que usan CSOM para .NET Framework. En CSOM para .NET Standard esto ya no es posible. Es el desarrollador que usa CSOM para .NET Standard para obtener un token de acceso de OAuth y usarlo para realizar llamadas a SharePoint Online.

El método recomendado para obtener tokens de acceso para SharePoint Online es configurar una aplicación de Azure AD. Para CSOM para .NET Standard lo único que importa es que se obtenga un token de acceso válido, ya que puede usar el flujo de credenciales de la contraseña del propietario del recurso mediante el inicio de sesión de dispositivo y la autenticación basada en certificados,

En el tema de métodos de CSOM no se han reescrito:

  • SaveBinaryDirect / OpenBinaryDirectAPI (basada en WebDAV)=> El motivo es claro. Para poder llegar al fichero es necesario tener una autenticación que hoy en día no está permitida. El uso de estos métodos se hacen dependiendo del tamaño de ficheros que queremos realizar (esto daría para un artículo solamente).
  • Microsoft.SharePoint.Client.Utilities.HttpUtility => Todos estos métodos que se utilizaban están escritos en el propio Framework con lo cual han hecho caso al no tener dos librerías que hacen lo mismo
  • EventReceiver => Aunque parezca mentira, aún estaban este tipo de Artefactos soportados en el Frameworks (a pesar de que todo el mundo ya usaba como desencadenadores otros artefactos de Azure como las mencionadas Azure Functions, Logic Apps,etc)

Veamos un ejemplo

Crear la aplicación en el Azure Active Directory

  • Ir a Azure AD portal a través de https://aad.portal.azure.com
  • Haz clic en Azure Active Directory y en los registros de aplicaciones en el panel de navegación izquierdo
  • Haz clic en nuevo registronuevo registro
  • Escribe un nombre para la aplicación y haz clic en registrar.
  • Ves a permisos de la API para conceder permisos a la aplicación, haz clic en Agregar permiso, elige SharePoint, permisos delegados y selecciona por ejemplo AllSites. administrar. Haz clic en conceder consentimiento de administrador para aceptar los permisos solicitados de la aplicación.dar permisos SharePoint
  • Haz clic en autenticación en el panel de navegación izquierdo
  • Cambia tipo de cliente predeterminado-tratar aplicación como cliente público de no a sí
  • Haz clic en información general y copia el identificador de la aplicación en el portapapeles (lo necesitaremos más adelante).

Crear la Aplicación de Consola

  • Crearemos una aplicación de consola. Bien usando la línea de comando o bien directamente desde Visual Studio
dotnet new console
  • Añadiremos los siguientes paquetes de nuget . Microsoft.SharePointOnline.CSOM . System.IdentityModel.Token.Jwt
  • Nos creamos una clase autenticacion manager
using Microsoft.SharePoint.Client;
using System;
using System.Collections.Concurrent;
using System.Net.Http;
using System.Security;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;

namespace Example_CSOM_NET_Standar
{
    public class AuthenticationManager : IDisposable
    {
        private static readonly HttpClient httpClient = new HttpClient();
        private const string tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/token";

        private const string defaultAADAppId = "clientid";

        // Token cache handling
        private static readonly SemaphoreSlim semaphoreSlimTokens = new SemaphoreSlim(1);
        private AutoResetEvent tokenResetEvent = null;
        private readonly ConcurrentDictionary<string, string> tokenCache = new ConcurrentDictionary<string, string>();
        private bool disposedValue;

        internal class TokenWaitInfo
        {
            public RegisteredWaitHandle Handle = null;
        }

        public ClientContext GetContext(Uri web, string userPrincipalName, SecureString userPassword)
        {
            var context = new ClientContext(web);

            context.ExecutingWebRequest += (sender, e) =>
            {
                string accessToken = EnsureAccessTokenAsync(new Uri($"{web.Scheme}://{web.DnsSafeHost}"), userPrincipalName, new System.Net.NetworkCredential(string.Empty, userPassword).Password).GetAwaiter().GetResult();
                e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
            };

            return context;
        }


        public async Task<string> EnsureAccessTokenAsync(Uri resourceUri, string userPrincipalName, string userPassword)
        {
            string accessTokenFromCache = TokenFromCache(resourceUri, tokenCache);
            if (accessTokenFromCache == null)
            {
                await semaphoreSlimTokens.WaitAsync().ConfigureAwait(false);
                try
                {
                    // No async methods are allowed in a lock section
                    string accessToken = await AcquireTokenAsync(resourceUri, userPrincipalName, userPassword).ConfigureAwait(false);
                    Console.WriteLine($"Successfully requested new access token resource {resourceUri.DnsSafeHost} for user {userPrincipalName}");
                    AddTokenToCache(resourceUri, tokenCache, accessToken);

                    // Register a thread to invalidate the access token once's it's expired
                    tokenResetEvent = new AutoResetEvent(false);
                    TokenWaitInfo wi = new TokenWaitInfo();
                    wi.Handle = ThreadPool.RegisterWaitForSingleObject(
                        tokenResetEvent,
                        async (state, timedOut) =>
                        {
                            if (!timedOut)
                            {
                                TokenWaitInfo wi = (TokenWaitInfo)state;
                                if (wi.Handle != null)
                                {
                                    wi.Handle.Unregister(null);
                                }
                            }
                            else
                            {
                                try
                                {
                                    // Take a lock to ensure no other threads are updating the SharePoint Access token at this time
                                    await semaphoreSlimTokens.WaitAsync().ConfigureAwait(false);
                                    RemoveTokenFromCache(resourceUri, tokenCache);
                                    Console.WriteLine($"Cached token for resource {resourceUri.DnsSafeHost} and user {userPrincipalName} expired");
                                }
                                catch (Exception ex)
                                {
                                    Console.WriteLine($"Something went wrong during cache token invalidation: {ex.Message}");
                                    RemoveTokenFromCache(resourceUri, tokenCache);
                                }
                                finally
                                {
                                    semaphoreSlimTokens.Release();
                                }
                            }
                        },
                        wi,
                        (uint)CalculateThreadSleep(accessToken).TotalMilliseconds,
                        true
                    );

                    return accessToken;

                }
                finally
                {
                    semaphoreSlimTokens.Release();
                }
            }
            else
            {
                Console.WriteLine($"Returning token from cache for resource {resourceUri.DnsSafeHost} and user {userPrincipalName}");
                return accessTokenFromCache;
            }
        }

        private async Task<string> AcquireTokenAsync(Uri resourceUri, string username, string password)
        {
            string resource = $"{resourceUri.Scheme}://{resourceUri.DnsSafeHost}";

            var clientId = defaultAADAppId;
            var body = $"resource={resource}&client_id={clientId}&grant_type=password&username={HttpUtility.UrlEncode(username)}&password={HttpUtility.UrlEncode(password)}";
            using (var stringContent = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"))
            {

                var result = await httpClient.PostAsync(tokenEndpoint, stringContent).ContinueWith((response) =>
                {
                    return response.Result.Content.ReadAsStringAsync().Result;
                }).ConfigureAwait(false);

                var tokenResult = JsonSerializer.Deserialize<JsonElement>(result);
                var token = tokenResult.GetProperty("access_token").GetString();
                return token;
            }
        }

        private static string TokenFromCache(Uri web, ConcurrentDictionary<string, string> tokenCache)
        {
            if (tokenCache.TryGetValue(web.DnsSafeHost, out string accessToken))
            {
                return accessToken;
            }

            return null;
        }

        private static void AddTokenToCache(Uri web, ConcurrentDictionary<string, string> tokenCache, string newAccessToken)
        {
            if (tokenCache.TryGetValue(web.DnsSafeHost, out string currentAccessToken))
            {
                tokenCache.TryUpdate(web.DnsSafeHost, newAccessToken, currentAccessToken);
            }
            else
            {
                tokenCache.TryAdd(web.DnsSafeHost, newAccessToken);
            }
        }

        private static void RemoveTokenFromCache(Uri web, ConcurrentDictionary<string, string> tokenCache)
        {
            tokenCache.TryRemove(web.DnsSafeHost, out string currentAccessToken);
        }

        private static TimeSpan CalculateThreadSleep(string accessToken)
        {
            var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(accessToken);
            var lease = GetAccessTokenLease(token.ValidTo);
            lease = TimeSpan.FromSeconds(lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds > 0 ? lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds : lease.TotalSeconds);
            return lease;
        }

        private static TimeSpan GetAccessTokenLease(DateTime expiresOn)
        {
            DateTime now = DateTime.UtcNow;
            DateTime expires = expiresOn.Kind == DateTimeKind.Utc ? expiresOn : TimeZoneInfo.ConvertTimeToUtc(expiresOn);
            TimeSpan lease = expires - now;
            return lease;
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    if (tokenResetEvent != null)
                    {
                        tokenResetEvent.Set();
                        tokenResetEvent.Dispose();
                    }
                }

                disposedValue = true;
            }
        }

        public void Dispose()
        {
            // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    }
}
  • Con esta clase ya podemos obtener un Token valido y hacer consultas contra SharePoint. Por ejemplo:
 public static async Task  Main(string[] args)
        {
            Console.WriteLine("Introduce el tenant");
            var tenant = Console.ReadLine();
            Console.WriteLine("Introduce el usuario");
            var user = Console.ReadLine();
            Console.WriteLine("Introduce el passworkd");
            var rawPassword = Console.ReadLine();
            Uri site = new Uri($"https://{tenant}.sharepoint.com/");
            
            SecureString password = new SecureString();
            foreach (char c in rawPassword) password.AppendChar(c);
            
            using (var authenticationManager = new AuthenticationManager())
            using (var context = authenticationManager.GetContext(site, user, password))
            {
                context.Load(context.Web, p => p.Title);
                await context.ExecuteQueryAsync();
                Console.WriteLine($"Title: {context.Web.Title}");
            }
        }

y el resultado seria el siguiente

dar permisos SharePoint

Conclusiones

Que CSOM lo podamos utilizar en NET Standard es un gran paso para todos los desarrolladores de SharePoint. Con la llegada de .NET 5 y los cambios que implica, era cuestión de tiempo que acabara sucediendo, sin embargo su adelanto es algo de gran ayuda para todos los desarrolladores de Office. A nivel de aprendizaje los métodos son exactamente iguales y por lo tanto no requiere de ningún aprendizaje extra.

Happy codding!

Código Fuente en Github

https://github.com/AdrianDiaz81/Example-CSOM-NET-Standar

Referencias

https://docs.microsoft.com/es-es/sharepoint/dev/sp-add-ins/using-csom-for-dotnet-standard

mm

Sobre Adrián Díaz

Adrián Díaz es Ingeniero Informático por la Universidad Politécnica de Valencia. Es MVP de Microsoft en la categoría Office Development desde 2014, MCPD de SharePoint 2010, Microsoft Active Profesional y Microsoft Comunity Contribuitor 2012. Cofundador del grupo de usuarios de SharePoint de Levante LevaPoint. Lleva desarrollando con tecnologías Microsoft más de 10 años y desde hace 3 años está centrado en el desarrollo sobre SharePoint. Actualmente es Software & Cloud Architect Lead en ENCAMINA.
Esta entrada ha sido publicada en .NET, sharepoint 2016. Enlace permanente.
Suscríbete a Desarrollando sobre SharePoint

Suscríbete a Desarrollando sobre SharePoint

Recibe todas las actualizaciones semanalmente de nuestro blog

You have Successfully Subscribed!

ENCAMINA, piensa en colores