205 lines
8.2 KiB
C#
205 lines
8.2 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Web;
|
|
using library_spo_utils.Interfaces.Services;
|
|
using library_spo_utils.Model;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.SharePoint.Client;
|
|
|
|
namespace library_spo_utils.Services
|
|
{
|
|
internal sealed class SharePointAuthenticationManager : IDisposable, ISharePointAuthenticationManager
|
|
{
|
|
private readonly ILogger<SharePointAuthenticationManager> logger;
|
|
private readonly ISiteOptions siteOptions;
|
|
|
|
public SharePointAuthenticationManager(
|
|
ILogger<SharePointAuthenticationManager> logger,
|
|
ISiteOptions siteOptions)
|
|
{
|
|
this.logger = logger;
|
|
this.siteOptions = siteOptions;
|
|
}
|
|
|
|
private static readonly HttpClient HttpClient = new();
|
|
|
|
private static readonly SemaphoreSlim SemaphoreSlimTokens = new(1);
|
|
private AutoResetEvent tokenResetEvent = null;
|
|
private readonly ConcurrentDictionary<string, string> tokenCache = new();
|
|
private bool disposedValue;
|
|
|
|
#region CSOM
|
|
public ClientContext GetContext(Uri web)
|
|
{
|
|
var context = new ClientContext(web);
|
|
|
|
context.ExecutingWebRequest += (sender, e) =>
|
|
{
|
|
var accessToken = EnsureAccessTokenAsync(new Uri($"{web.Scheme}://{web.DnsSafeHost}"),
|
|
siteOptions.GetUser(),
|
|
new System.Net.NetworkCredential(string.Empty, siteOptions.GetPassword()).Password)
|
|
.GetAwaiter()
|
|
.GetResult();
|
|
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
|
|
};
|
|
|
|
return context;
|
|
}
|
|
|
|
public async Task<string> EnsureAccessTokenAsync(Uri resourceUri, string userPrincipalName, string userPassword)
|
|
{
|
|
var accessTokenFromCache = TokenFromCache(resourceUri, tokenCache);
|
|
if (accessTokenFromCache == null)
|
|
{
|
|
await SemaphoreSlimTokens.WaitAsync();
|
|
try
|
|
{
|
|
// No async methods are allowed in a lock section
|
|
var accessToken = await AcquireTokenAsync(resourceUri, userPrincipalName, userPassword);
|
|
logger.LogInformation($"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);
|
|
var wi = new TokenWaitInfo();
|
|
wi.Handle = ThreadPool.RegisterWaitForSingleObject(
|
|
tokenResetEvent,
|
|
(state, timedOut) =>
|
|
{
|
|
if (!timedOut)
|
|
{
|
|
var internalWaitToken = (TokenWaitInfo)state;
|
|
if (internalWaitToken?.Handle is not null)
|
|
{
|
|
internalWaitToken.Handle.Unregister(null);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
// Take a lock to ensure no other threads are updating the SharePoint Access token at this time
|
|
SemaphoreSlimTokens.Wait();
|
|
RemoveTokenFromCache(resourceUri, tokenCache);
|
|
logger.LogInformation($"Cached token for resource {resourceUri.DnsSafeHost} and user {userPrincipalName} expired");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogInformation($"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
|
|
{
|
|
logger.LogInformation("OK - Execution Query");
|
|
return accessTokenFromCache;
|
|
}
|
|
}
|
|
|
|
private async Task<string> AcquireTokenAsync(Uri resourceUri, string username, string password)
|
|
{
|
|
var resource = $"{resourceUri.Scheme}://{resourceUri.DnsSafeHost}";
|
|
|
|
var clientId = siteOptions.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(siteOptions.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 var accessToken))
|
|
{
|
|
return accessToken;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static void AddTokenToCache(Uri web, ConcurrentDictionary<string, string> tokenCache, string newAccessToken)
|
|
{
|
|
if (tokenCache.TryGetValue(web.DnsSafeHost, out var 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 _);
|
|
}
|
|
|
|
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)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var expires = expiresOn.Kind == DateTimeKind.Utc ? expiresOn : TimeZoneInfo.ConvertTimeToUtc(expiresOn);
|
|
var lease = expires - now;
|
|
return lease;
|
|
}
|
|
|
|
private void Dispose(bool disposing)
|
|
{
|
|
if (disposedValue)
|
|
{
|
|
return;
|
|
}
|
|
if (disposing)
|
|
{
|
|
if (tokenResetEvent is not 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);
|
|
}
|
|
#endregion
|
|
}
|
|
}
|