Skip to content

Commit

Permalink
Merge pull request #20 from easymorph/development/5.3.2
Browse files Browse the repository at this point in the history
5.3.2
  • Loading branch information
strongtigerman authored Mar 1, 2023
2 parents 9749f78 + 67a007c commit d6bed3e
Show file tree
Hide file tree
Showing 20 changed files with 725 additions and 63 deletions.
286 changes: 286 additions & 0 deletions src/Client/ApiSessionRefresher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Morph.Server.Sdk.Dto.Errors;
using Morph.Server.Sdk.Helper;
using Morph.Server.Sdk.Model;
using Morph.Server.Sdk.Model.InternalModels;

namespace Morph.Server.Sdk.Client
{
/// <summary>
/// Service that provides seamless API token refresh
/// </summary>
public class ApiSessionRefresher : IApiSessionRefresher
{
/// <summary>
/// DTO for '/user/me' endpoint. That endpoint returns more info, but here we only interested in one field to check whether
/// we're authenticated
/// </summary>
[DataContract]
private class UserMeDtoStub
{
[DataMember(Name = "isAuthenticated")] public bool? IsAuthenticated { get; set; }
}

private class Container<T>
{
public readonly T Value;
public readonly DateTime Time;

public Container(T value)
{
Value = value;
Time = DateTime.UtcNow;
}
}

private readonly ConcurrentDictionary<string, Container<Authenticator>> _authenticators =
new ConcurrentDictionary<string, Container<Authenticator>>();

private readonly ConcurrentDictionary<ApiSession, DateTime> _sessions =
new ConcurrentDictionary<ApiSession, DateTime>();

private readonly ConcurrentDictionary<string, Container<Task<ApiSession>>> _refreshTasks =
new ConcurrentDictionary<string, Container<Task<ApiSession>>>();

private readonly IJsonSerializer _serializer = new MorphDataContractJsonSerializer();

public async Task<bool> RefreshSessionAsync(HeadersCollection headers, CancellationToken token)
{
var expiredSessionToken = headers.GetValueOrDefault(ApiSession.AuthHeaderName);

if (string.IsNullOrEmpty(expiredSessionToken))
return false;

var authenticator = _authenticators.TryRemove(expiredSessionToken, out var value) ? value : null;

try
{
// Here we utilize the natural behavior of TPL/async that fits our needs perfectly.
// We want the first user that wants a replacement for session token 'A' to initiate re-auth process and receive 'B'
// (for example) as a new token, but we also want subsequent requests for 'A' replacement to just return 'B',
// without extra server-trips that would entail new tokens being generated, which in turn would result in
// session proliferation.

// A dictionary with Tasks and a generator would do just that:
// first client to request new session token to replace 'A' would create an actual Task<ApiSession> that would yield
// a new ApiSession instance, and subsequent clients would just get that same Task<ApiSession> instance, which
// upon being awaited would yield the same ApiSession instance.

var result = await _refreshTasks.GetOrAdd(expiredSessionToken,
oldToken => new Container<Task<ApiSession>>(RefreshSessionCore(oldToken, authenticator, token))).Value;

result = UpdateSessionToRecent(result);

if (string.IsNullOrWhiteSpace(result?.AuthToken))
{
//This is not expected behavior and at least we don't want to cache 'null' session as a replacement
//for 'expiredSessionToken'. Remove cached task, re-add authenticator.
//Same logic should be applied if authenticator results in exception.

if (authenticator != null)
_authenticators.TryAdd(expiredSessionToken, authenticator);

_refreshTasks.TryRemove(expiredSessionToken, out _);
return false;
}

headers.Set(ApiSession.AuthHeaderName, result.AuthToken);

foreach (var pair in _sessions.Where(s =>
string.Equals(s.Key?.AuthToken, expiredSessionToken, StringComparison.Ordinal)))
{
pair.Key.FillFrom(result);

// Update last session access time.
_sessions.TryUpdate(pair.Key, DateTime.UtcNow, pair.Value);
}

return true;
}
catch (Exception)
{
//Revert authenticator to original state for possible retry.
if (authenticator != null)
_authenticators.TryAdd(expiredSessionToken, authenticator);

//Do not cache faulted/cancelled tasks
_refreshTasks.TryRemove(expiredSessionToken, out _);
throw;
}
}

private async Task<ApiSession> RefreshSessionCore(string oldToken, Container<Authenticator> authenticator, CancellationToken token)
{
if (null == authenticator)
return null;

var freshSession = await authenticator.Value(token);

if (null == freshSession)
return null;

// Find tasks that are completed and have previously returned 'oldToken' as their result and replace them with task
// that returns our fresh session.
//
// This way, if we for example had token 'A' exchanged for 'B' and then 'B' got invalidated and exchanged for 'C', then
// when something that was still holding token 'A' tries to refresh it, it will get good 'C' instead of already invalid 'B'.

var toRedirect = _refreshTasks
.Where(x => x.Value.Value.Status == TaskStatus.RanToCompletion &&
string.Equals(x.Value.Value.Result?.AuthToken, oldToken, StringComparison.Ordinal))
.ToArray();

foreach (var kvp in toRedirect)
_refreshTasks.TryUpdate(kvp.Key, new Container<Task<ApiSession>>(Task.FromResult(freshSession)), kvp.Value);

return freshSession;
}


/// <summary>
/// Given session instance, returns most recent replacement for it, if any.
/// </summary>
/// <param name="source">Session that was retrieved as re-auth result</param>
/// <returns>Updated session or original <see cref="source"/></returns>
private ApiSession UpdateSessionToRecent(ApiSession source)
{
if (string.IsNullOrWhiteSpace(source?.AuthToken))
return source;

// There is (some) possibility that during the slow~ish resolution of session A->B another concurrent
// request had already exchanged B for C. In this case we would like the request for A to get the most recent
// session token 'C', not 'B'.

// UML sequence diagram below to clarify desired behavior.
// Both Client 1 and 2 start with shared session token 'A' and then both want to refresh it ⬇
//
// ┌───────┐ ┌────────────────┐ ┌───────┐
// │Client1│ │SessionRefresher│ │Client2│
// └───┬───┘ └───────┬────────┘ └───┬───┘
// │ refresh 'A' │ │
// │───────────────────────> │
// │ │ │
// │ │ refresh 'A' │
// │ │ <───────────────────────────────────│
// │ │ │
// │take 'B' instead of 'A'│ │
// │<─────────────────────── │
// │ │ │
// │ refresh 'B' │ │
// │───────────────────────> │
// │ │ │
// │take 'C' instead of 'B'│ │
// │<─────────────────────── │
// │ │ │
// │ │ should be 'C', not 'B' or (new) 'D' │
// │ │ ───────────────────────────────────>│
//
//
// Code below checks whether there are tasks that have already completed and were stemmed from the
// session token we're trying to refresh. If there are, we take the most recent one and return it.

return _refreshTasks
.Where(x => x.Value.Value.Status == TaskStatus.RanToCompletion && x.Key == source?.AuthToken)
.Select(x => x.Value.Value.Result).FirstOrDefault() ?? source;
}

public void AssociateAuthenticator(ApiSession session, Authenticator authenticator)
{
if (null == session?.AuthToken)
return;

_authenticators.TryAdd(session.AuthToken, new Container<Authenticator>(authenticator));
_sessions.TryAdd(session, DateTime.UtcNow);

PruneCache();
}

private void PruneCache()
{
var removeBefore = DateTime.UtcNow - TimeSpan.FromHours(5);

// Remove everything that was last touched 5 hours ago.

foreach (var pair in _authenticators.Where(c => c.Value.Time < removeBefore))
_authenticators.TryRemove(pair.Key, out _);

foreach (var pair in _refreshTasks.Where(c => c.Value.Time < removeBefore))
_refreshTasks.TryRemove(pair.Key, out _);

foreach (var pair in _sessions.Where(c => c.Value < removeBefore))
_sessions.TryRemove(pair.Key, out _);
}

public async Task<bool> IsSessionLostResponse(HeadersCollection headersCollection, string path, HttpContent httpContent, HttpResponseMessage response)
{
// Session-lost or expired errors have 403 error code, not 401, due to some EMS/http.sys error handling workarounds
if (response.StatusCode != System.Net.HttpStatusCode.Forbidden)
return false;

// Anonymous sessions are not refreshable.
if (!headersCollection.Contains(ApiSession.AuthHeaderName))
return false;

// Can't refresh if something fails during refresh.
if (path.StartsWith("auth/", StringComparison.OrdinalIgnoreCase))
return false;

//Cannot retry if request had streaming content (can't rewind it).
if (httpContent is StreamContent)
return false;

if (httpContent is MultipartContent multipartContent)
{
if (multipartContent.Any(part =>
part is ContiniousSteamingHttpContent
|| part is StreamContent
|| part is ProgressStreamContent))
return false;
}

// Session-lost error body should be json
if(!string.Equals(response.Content?.Headers.ContentType.MediaType, "application/json", StringComparison.OrdinalIgnoreCase))
return false;

var content = await response.Content.ReadAsStringAsync();

if(string.IsNullOrWhiteSpace(content))
return false;

try
{
var responseModel = _serializer.Deserialize<ErrorResponse>(content);

return string.Equals(responseModel?.error.code, ReadableErrorTopCode.Unauthorized, StringComparison.Ordinal);
}
catch (Exception)
{
//Not a valid JSON or doesn't match error schema - not our case
return false;
}
}

public async Task EnsureSessionValid(MorphServerRestClient restClient, HeadersCollection headersCollection,
CancellationToken token)
{
//This is an anonymous session -- we can't refresh it.
if (!headersCollection.Contains(ApiSession.AuthHeaderName))
return;

var userMe = await restClient.GetAsync<UserMeDtoStub>(UrlHelper.JoinUrl("user", "me"), null, headersCollection, token);

if (userMe?.Data.IsAuthenticated == true)
return;

await RefreshSessionAsync(headersCollection, token);
}


}
}
17 changes: 17 additions & 0 deletions src/Client/AppWideSessionRefresher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using System.Threading;

namespace Morph.Server.Sdk.Client
{
/// <summary>
/// Provides one-per-AppDomain session refresher to be used as sensible default/fallback refresher
/// </summary>
internal static class AppWideSessionRefresher
{
private static readonly Lazy<ApiSessionRefresher> Provider = new Lazy<ApiSessionRefresher>(
() => new ApiSessionRefresher(),
LazyThreadSafetyMode.ExecutionAndPublication);

public static ApiSessionRefresher Instance => Provider.Value;
}
}
56 changes: 56 additions & 0 deletions src/Client/IApiSessionRefresher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Morph.Server.Sdk.Model;
using Morph.Server.Sdk.Model.InternalModels;

namespace Morph.Server.Sdk.Client
{

/// <summary>
/// Delegate that upon being invoked performs server-side authentication and returns a fresh valid API Session
/// </summary>
public delegate Task<ApiSession> Authenticator(CancellationToken token);

/// <summary>
/// Service that provides seamless API token refresh
/// </summary>
public interface IApiSessionRefresher
{
/// <summary>
/// Re-authenticates current session (if any) and seamlessly updates existing <see cref="ApiSession"/> instances that were associated
/// with the previous session via <see cref="AssociateAuthenticator"/> method.
/// </summary>
/// <param name="headers">Request headers</param>
/// <param name="token">Cancellation token</param>
/// <returns></returns>
Task<bool> RefreshSessionAsync(HeadersCollection headers, CancellationToken token);

/// <summary>
/// Save <see cref="authenticator"/> for <see cref="session"/>. T
/// his is required for <see cref="ApiSession"/> object tracking and for <see cref="RefreshSessionAsync"/> to work.
/// </summary>
/// <param name="session"></param>
/// <param name="authenticator"></param>
void AssociateAuthenticator(ApiSession session, Authenticator authenticator);

/// <summary>
/// Returns true if <see cref="response"/> indicates that the session has expired
/// </summary>
/// <param name="headersCollection"></param>
/// <param name="path"></param>
/// <param name="httpContent"></param>
/// <param name="response"></param>
/// <returns></returns>
Task<bool> IsSessionLostResponse(HeadersCollection headersCollection, string path, HttpContent httpContent, HttpResponseMessage response);

/// <summary>
/// Ensures that current session is valid and refreshes it if needed
/// </summary>
/// <param name="restClient"></param>
/// <param name="headersCollection"></param>
/// <param name="token"></param>
/// <returns></returns>
Task EnsureSessionValid(MorphServerRestClient restClient, HeadersCollection headersCollection, CancellationToken token);
}
}
18 changes: 14 additions & 4 deletions src/Client/ILowLevelApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ Task<ApiResult<WorkflowResultDetailsDto>> GetWorkflowResultDetailsAsync(ApiSessi
Task<ApiResult<SpaceBrowsingResponseDto>> WebFilesBrowseSpaceAsync(ApiSession apiSession, string folderPath, CancellationToken cancellationToken);
Task<ApiResult<bool>> WebFileExistsAsync(ApiSession apiSession, string serverFilePath, CancellationToken cancellationToken);
Task<ApiResult<NoContentResult>> WebFilesDeleteFileAsync(ApiSession apiSession, string serverFilePath, CancellationToken cancellationToken);

Task<ApiResult<NoContentResult>> WebFilesDeleteFolderAsync(ApiSession apiSession, string serverFilePath,
bool failIfNotExists,
CancellationToken cancellationToken);

Task<ApiResult<NoContentResult>> WebFilesCreateFolderAsync(ApiSession apiSession, string parentFolderPath,
string folderName,
bool failIfExists, CancellationToken cancellationToken);

Task<ApiResult<NoContentResult>> WebFilesRenameFolderAsync(ApiSession apiSession, string parentFolderPath,
string oldFolderName, string newFolderName,
bool failIfExists, CancellationToken cancellationToken);

Task<ApiResult<FetchFileStreamData>> WebFilesDownloadFileAsync(ApiSession apiSession, string serverFilePath, Action<FileTransferProgressEventArgs> onReceiveProgress, CancellationToken cancellationToken);
Task<ApiResult<NoContentResult>> WebFilesPutFileStreamAsync(ApiSession apiSession, string serverFolder, SendFileStreamData sendFileStreamData, Action<FileTransferProgressEventArgs> onSendProgress, CancellationToken cancellationToken);
Task<ApiResult<NoContentResult>> WebFilesPostFileStreamAsync(ApiSession apiSession, string serverFolder, SendFileStreamData sendFileStreamData, Action<FileTransferProgressEventArgs> onSendProgress, CancellationToken cancellationToken);
Expand All @@ -66,7 +79,4 @@ Task<ApiResult<WorkflowResultDetailsDto>> GetWorkflowResultDetailsAsync(ApiSessi
Task<ApiResult<ServerPushStreaming>> WebFilesOpenContiniousPutStreamAsync(ApiSession apiSession, string serverFolder, string fileName, CancellationToken cancellationToken);

}
}



}
Loading

0 comments on commit d6bed3e

Please sign in to comment.