diff options
author | vman <public@vaughnnugent.com> | 2022-10-30 02:28:12 -0400 |
---|---|---|
committer | vman <public@vaughnnugent.com> | 2022-10-30 02:28:12 -0400 |
commit | a8510fb835dcc5e1142d700164ce5a4bd44e1a25 (patch) | |
tree | 28caab320f777a384cb6883b68dd999cdc8c0a3f /Libs |
Add project files.
Diffstat (limited to 'Libs')
34 files changed, 2461 insertions, 0 deletions
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/AccessTokenEndpoint.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/AccessTokenEndpoint.cs new file mode 100644 index 0000000..271328a --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/AccessTokenEndpoint.cs @@ -0,0 +1,109 @@ +using System; +using System.Net; + +using VNLib.Utils.Memory; +using VNLib.Plugins.Essentials.Oauth; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Sql; + + +namespace VNLib.Plugins.Essentials.Sessions.OAuth.Endpoints +{ + /// <summary> + /// Grants authorization to OAuth2 clients to protected resources + /// with access tokens + /// </summary> + internal sealed class AccessTokenEndpoint : ResourceEndpointBase + { + + private readonly Lazy<ITokenManager> TokenStore; + private readonly Applications Applications; + + //override protection settings to allow most connections to authenticate + protected override ProtectionSettings EndpointProtectionSettings { get; } = new() + { + BrowsersOnly = false, + SessionsRequired = false, + VerifySessionCors = false + }; + + public AccessTokenEndpoint(string path, PluginBase pbase, Lazy<ITokenManager> tokenStore) + { + InitPathAndLog(path, pbase.Log); + TokenStore = tokenStore; + Applications = new(pbase.GetContextOptions(), pbase.GetPasswords()); + } + + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + //Check for refresh token + if (entity.RequestArgs.IsArgumentSet("grant_type", "refresh_token")) + { + //process a refresh token + } + //Check for grant_type parameter from the request body + if (!entity.RequestArgs.IsArgumentSet("grant_type", "client_credentials")) + { + entity.CloseResponseError(HttpStatusCode.BadRequest, ErrorType.InvalidClient, "Invalid grant type"); + //Default to bad request + return VfReturnType.VirtualSkip; + } + //Get client id and secret (and make sure theyre not empty + if (entity.RequestArgs.TryGetNonEmptyValue("client_id", out string? clientId) && + entity.RequestArgs.TryGetNonEmptyValue("client_secret", out string? secret)) + { + if (!ValidatorExtensions.OnlyAlphaNumRegx.IsMatch(clientId)) + { + //respond with error message + entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidRequest, "Invalid client_id"); + return VfReturnType.VirtualSkip; + } + if (!ValidatorExtensions.OnlyAlphaNumRegx.IsMatch(secret)) + { + //respond with error message + entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidRequest, "Invalid client_secret"); + return VfReturnType.VirtualSkip; + } + //Convert the clientid and secret to lowercase + clientId = clientId.ToLower(); + secret = secret.ToLower(); + //Convert secret to private string + PrivateString secretPv = new(secret, false); + //Get the application from apps store + UserApplication? app = await Applications.VerifyAppAsync(clientId, secretPv); + if (app == null) + { + //App was not found or the credentials do not match + entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidClient, "The client credentials are invalid"); + return VfReturnType.VirtualSkip; + } + //Create a new session + IOAuth2TokenResult? result = await TokenStore.Value.CreateAccessTokenAsync(entity.Entity, app, entity.EventCancellation); + if (result == null) + { + entity.CloseResponseError(HttpStatusCode.ServiceUnavailable, ErrorType.TemporarilyUnabavailable, "You have reached the maximum number of valid tokens for this application"); + return VfReturnType.VirtualSkip; + } + //Create the new response message + OauthTokenResponseMessage tokenMessage = new() + { + AccessToken = result.AccessToken, + + //set expired as seconds in int form + Expires = result.ExpiresSeconds, + RefreshToken = result.RefreshToken, + TokenType = result.TokenType + }; + //Respond with the token message + entity.CloseResponseJson(HttpStatusCode.OK, tokenMessage); + return VfReturnType.VirtualSkip; + + } + //respond with error message + entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidClient, "The request was missing required arguments"); + return VfReturnType.VirtualSkip; + } + } +}
\ No newline at end of file diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/RevocationEndpoint.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/RevocationEndpoint.cs new file mode 100644 index 0000000..095e07e --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/RevocationEndpoint.cs @@ -0,0 +1,31 @@ +using System; +using System.Text.Json; + +using VNLib.Plugins.Essentials.Oauth; +using VNLib.Plugins.Extensions.Loading.Configuration; + +namespace VNLib.Plugins.Essentials.Sessions.OAuth.Endpoints +{ + /// <summary> + /// An OAuth2 authorized endpoint for revoking the access token + /// held by the current connection + /// </summary> + [ConfigurationName("oauth2")] + internal class RevocationEndpoint : O2EndpointBase + { + + public RevocationEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config) + { + string? path = config["revocation_path"].GetString(); + InitPathAndLog(path, pbase.Log); + } + + protected override VfReturnType Post(HttpEntity entity) + { + //Revoke the access token, by invalidating it + entity.Session.Invalidate(); + entity.CloseResponse(System.Net.HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/IOauthSessionIdFactory.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/IOauthSessionIdFactory.cs new file mode 100644 index 0000000..9a65d62 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/IOauthSessionIdFactory.cs @@ -0,0 +1,38 @@ +using System; + +using VNLib.Net.Http; +using VNLib.Plugins.Essentials.Oauth; +using VNLib.Plugins.Sessions.Cache.Client; + +namespace VNLib.Plugins.Essentials.Sessions.OAuth +{ + public interface IOauthSessionIdFactory : ISessionIdFactory + { + /// <summary> + /// The maxium number of tokens allowed to be created per OAuth application + /// </summary> + int MaxTokensPerApp { get; } + /// <summary> + /// Allows for custom configuration of the newly created session and + /// the <see cref="IHttpEvent"/> its attached to + /// </summary> + /// <param name="session">The newly created session</param> + /// <param name="app">The application associated with the session</param> + /// <param name="entity">The http event that generated the new session</param> + void InitNewSession(RemoteSession session, UserApplication app, IHttpEvent entity); + /// <summary> + /// The time a session is valid for + /// </summary> + TimeSpan SessionValidFor { get; } + /// <summary> + /// Called when the session provider wishes to generate a new session + /// and required credential information to generate the new session + /// </summary> + /// <returns>The information genreated for the news ession</returns> + TokenAndSessionIdResult GenerateTokensAndId(); + /// <summary> + /// The type of token this session provider generates + /// </summary> + string TokenType { get; } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/O2SessionProviderEntry.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/O2SessionProviderEntry.cs new file mode 100644 index 0000000..e15c6e4 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/O2SessionProviderEntry.cs @@ -0,0 +1,117 @@ + +using System.Text.Json; + +using VNLib.Net.Http; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Oauth; +using VNLib.Plugins.Essentials.Sessions.OAuth; +using VNLib.Plugins.Essentials.Sessions.OAuth.Endpoints; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Sql; +using VNLib.Plugins.Extensions.Loading.Events; +using VNLib.Plugins.Extensions.Loading.Routing; +using VNLib.Plugins.Extensions.Loading.Configuration; + +namespace VNLib.Plugins.Essentials.Sessions.Oauth +{ + public sealed class O2SessionProviderEntry : IRuntimeSessionProvider + { + const string VNCACHE_CONFIG_KEY = "vncache"; + const string OAUTH2_CONFIG_KEY = "oauth2"; + + private OAuth2SessionProvider? _sessions; + + bool IRuntimeSessionProvider.CanProcess(IHttpEvent entity) + { + //If authorization header is set try to process as oauth2 session + return entity.Server.Headers.HeaderSet(System.Net.HttpRequestHeader.Authorization); + } + + ValueTask<SessionHandle> ISessionProvider.GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken) + { + return _sessions!.GetSessionAsync(entity, cancellationToken); + } + + + void IRuntimeSessionProvider.Load(PluginBase plugin, ILogProvider localized) + { + //Try get vncache config element + IReadOnlyDictionary<string, JsonElement> cacheConfig = plugin.GetConfig(VNCACHE_CONFIG_KEY); + + IReadOnlyDictionary<string, JsonElement> oauth2Config = plugin.GetConfig(OAUTH2_CONFIG_KEY); + + string tokenEpPath = oauth2Config["token_path"].GetString() ?? throw new KeyNotFoundException($"Missing required 'token_path' in '{OAUTH2_CONFIG_KEY}' config"); + + //TODO fix with method that will wait until cache is actually loaded + Lazy<ITokenManager> lazyTokenMan = new(() => _sessions!, false); + + //Init auth endpoint + AccessTokenEndpoint authEp = new(tokenEpPath, plugin, lazyTokenMan); + + //route auth endpoint + plugin.Route(authEp); + + //Route revocation endpoint + plugin.Route<RevocationEndpoint>(); + + //Run + _ = WokerDoWorkAsync(plugin, localized, cacheConfig, oauth2Config); + } + + /* + * Starts and monitors the VNCache connection + */ + + private async Task WokerDoWorkAsync(PluginBase plugin, ILogProvider localized, IReadOnlyDictionary<string, JsonElement> cacheConfig, IReadOnlyDictionary<string, JsonElement> oauth2Config) + { + //Init cache client + using VnCacheClient cache = new(plugin.IsDebug() ? plugin.Log : null, Utils.Memory.Memory.Shared); + + try + { + int cacheLimit = oauth2Config["cache_size"].GetInt32(); + int maxTokensPerApp = oauth2Config["max_tokens_per_app"].GetInt32(); + int sessionIdSize = (int)oauth2Config["access_token_size"].GetUInt32(); + TimeSpan tokenValidFor = oauth2Config["token_valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); + TimeSpan cleanupInterval = oauth2Config["gc_interval_sec"].GetTimeSpan(TimeParseType.Seconds); + string sessionIdPrefix = oauth2Config["cache_prefix"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'cache_prefix' in '{OAUTH2_CONFIG_KEY}' config"); + + //init the id provider + OAuth2SessionIdProvider idProv = new(sessionIdPrefix, maxTokensPerApp, sessionIdSize, tokenValidFor); + + //Try loading config + await cache.LoadConfigAsync(plugin, cacheConfig); + + //Init session provider now that client is loaded + _sessions = new(cache.Resource!, cacheLimit, idProv, plugin.GetContextOptions()); + + //Schedule cleanup interval with the plugin scheduler + plugin.ScheduleInterval(_sessions, cleanupInterval); + + + localized.Information("Session provider loaded"); + + //Run and wait for exit + await cache.RunAsync(localized, plugin.UnloadToken); + + } + catch (OperationCanceledException) + {} + catch (KeyNotFoundException e) + { + localized.Error("Missing required configuration variable for VnCache client: {0}", e.Message); + } + catch (Exception ex) + { + localized.Error(ex, "Cache client error occured in session provider"); + } + finally + { + _sessions = null; + } + + localized.Information("Cache client exited"); + } + } +}
\ No newline at end of file diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2Session.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2Session.cs new file mode 100644 index 0000000..5987c81 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2Session.cs @@ -0,0 +1,107 @@ +using System; + +using VNLib.Net.Http; +using VNLib.Net.Messaging.FBM.Client; +using VNLib.Plugins.Sessions.Cache.Client; + +using static VNLib.Plugins.Essentials.Sessions.ISessionExtensions; + +namespace VNLib.Plugins.Essentials.Sessions.OAuth +{ + /// <summary> + /// The implementation of the OAuth2 session container for HTTP sessions + /// </summary> + internal sealed class OAuth2Session : RemoteSession + { + private readonly Action<OAuth2Session> InvalidateCache; + + /// <summary> + /// Initalizes a new <see cref="OAuth2Session"/> + /// </summary> + /// <param name="sessionId">The session id (or token)</param> + /// <param name="client">The <see cref="FBMClient"/> used as the backing cache provider</param> + /// <param name="backgroundTimeOut">The ammount of time to wait for a background operation (delete, update, get)</param> + /// <param name="invalidCache">Called when the session has been marked as invalid and the close even hook is being executed</param> + public OAuth2Session(string sessionId, FBMClient client, TimeSpan backgroundTimeOut, Action<OAuth2Session> invalidCache) + : base(sessionId, client, backgroundTimeOut) + { + InvalidateCache = invalidCache; + IsInvalid = false; + } + + public bool IsInvalid { get; private set; } + + + ///<inheritdoc/> + ///<exception cref="NotSupportedException"></exception> + public override string Token + { + get => throw new NotSupportedException("Token property is not supported for OAuth2 sessions"); + set => throw new NotSupportedException("Token property is not supported for OAuth2 sessions"); + } + + ///<inheritdoc/> + protected override void IndexerSet(string key, string value) + { + //Guard protected entires + switch (key) + { + case TOKEN_ENTRY: + case LOGIN_TOKEN_ENTRY: + throw new InvalidOperationException("Token entry may not be changed!"); + } + base.IndexerSet(key, value); + } + ///<inheritdoc/> + ///<exception cref="SessionStatusException"></exception> + public override async Task WaitAndLoadAsync(IHttpEvent entity, CancellationToken token = default) + { + //Wait to enter lock + await base.WaitAndLoadAsync(entity, token); + if (IsInvalid) + { + //Release lock + MainLock.Release(); + throw new SessionStatusException("The session has been invalidated"); + } + //Set session type + if (IsNew) + { + SessionType = SessionType.OAuth2; + } + } + ///<inheritdoc/> + protected override async ValueTask<Task?> UpdateResource(bool isAsync, IHttpEvent state) + { + Task? result = null; + //Invalid flag is set, so exit + if (IsInvalid) + { + result = Task.CompletedTask; + } + //Check flags in priority level, Invalid is highest state priority + else if (Flags.IsSet(INVALID_MSK)) + { + //Clear all stored values + DataStore!.Clear(); + //Delete the entity syncronously + await ProcessDeleteAsync(); + //Set invalid flag + IsInvalid = true; + //Invlidate cache + InvalidateCache(this); + result = Task.CompletedTask; + } + else if (Flags.IsSet(MODIFIED_MSK)) + { + //Send update to server + result = Task.Run(ProcessUpdateAsync); + } + + //Clear all flags + Flags.ClearAll(); + + return result; + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionIdProvider.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionIdProvider.cs new file mode 100644 index 0000000..2b23721 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionIdProvider.cs @@ -0,0 +1,109 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Hashing; +using VNLib.Net.Http; +using VNLib.Plugins.Essentials.Oauth; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Sessions.Cache.Client; +using static VNLib.Plugins.Essentials.Oauth.OauthSessionExtensions; + + +namespace VNLib.Plugins.Essentials.Sessions.OAuth +{ + /// <summary> + /// Generates secure OAuth2 session tokens and initalizes new OAuth2 sessions + /// </summary> + internal class OAuth2SessionIdProvider : IOauthSessionIdFactory + { + private readonly string SessionIdPrefix; + private readonly int _bufferSize; + private readonly int _tokenSize; + + ///<inheritdoc/> + public int MaxTokensPerApp { get; } + ///<inheritdoc/> + public TimeSpan SessionValidFor { get; } + ///<inheritdoc/> + string IOauthSessionIdFactory.TokenType => "Bearer"; + + public OAuth2SessionIdProvider(string sessionIdPrefix, int maxTokensPerApp, int tokenSize, TimeSpan validFor) + { + SessionIdPrefix = sessionIdPrefix; + MaxTokensPerApp = maxTokensPerApp; + SessionValidFor = validFor; + _tokenSize = tokenSize; + _bufferSize = tokenSize * 2; + } + + ///<inheritdoc/> + bool ISessionIdFactory.TryGetSessionId(IHttpEvent entity, [NotNullWhen(true)] out string? sessionId) + { + //Get authorization token and make sure its not too large to cause a buffer overflow + if (entity.Server.HasAuthorization(out string? token) && (token.Length + SessionIdPrefix.Length) <= _bufferSize) + { + //Compute session id from token + sessionId = ComputeSessionIdFromToken(token); + + return true; + } + else + { + sessionId = null; + } + + return false; + } + + private string ComputeSessionIdFromToken(string token) + { + //Buffer to copy data to + using UnsafeMemoryHandle<char> buffer = Memory.UnsafeAlloc<char>(_bufferSize, true); + + //Writer to accumulate data + ForwardOnlyWriter<char> writer = new(buffer.Span); + + //Append session id prefix and token + writer.Append(SessionIdPrefix); + writer.Append(token); + + //Compute base64 hash of token and + return ManagedHash.ComputeBase64Hash(writer.AsSpan(), HashAlg.SHA256); + } + + ///<inheritdoc/> + TokenAndSessionIdResult IOauthSessionIdFactory.GenerateTokensAndId() + { + //Alloc buffer for random data + using UnsafeMemoryHandle<byte> mem = Memory.UnsafeAlloc<byte>(_tokenSize, true); + + //Generate token from random cng bytes + RandomHash.GetRandomBytes(mem); + + //Token is the raw value + string token = Convert.ToBase64String(mem.Span); + + //The session id is the HMAC of the token + string sessionId = ComputeSessionIdFromToken(token); + + //Clear buffer + Utils.Memory.Memory.InitializeBlock(mem.Span); + + //Return sessid result + return new(sessionId, token, null); + } + + ///<inheritdoc/> + void IOauthSessionIdFactory.InitNewSession(RemoteSession session, UserApplication app, IHttpEvent entity) + { + //Store session variables + session[APP_ID_ENTRY] = app.Id; + session[TOKEN_TYPE_ENTRY] = "client_credential,bearer"; + session[SCOPES_ENTRY] = app.Permissions; + session.UserID = app.UserId; + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionProvider.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionProvider.cs new file mode 100644 index 0000000..d938641 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionProvider.cs @@ -0,0 +1,183 @@ +using System; +using System.Net; + +using Microsoft.EntityFrameworkCore; + +using VNLib.Utils; +using VNLib.Utils.Logging; +using VNLib.Net.Http; +using VNLib.Data.Caching; +using VNLib.Data.Caching.Exceptions; +using VNLib.Net.Messaging.FBM.Client; +using VNLib.Plugins.Essentials.Oauth; +using VNLib.Plugins.Essentials.Oauth.Tokens; +using VNLib.Plugins.Sessions.Cache.Client; +using VNLib.Plugins.Extensions.Loading.Events; + +namespace VNLib.Plugins.Essentials.Sessions.OAuth +{ + + /// <summary> + /// Provides OAuth2 session management + /// </summary> + internal sealed class OAuth2SessionProvider : SessionCacheClient, ISessionProvider, ITokenManager, IIntervalScheduleable + { + + private static readonly SessionHandle NotFoundHandle = new(null, FileProcessArgs.NotFound, null); + static readonly TimeSpan BackgroundTimeout = TimeSpan.FromSeconds(10); + + private readonly IOauthSessionIdFactory factory; + private readonly TokenStore TokenStore; + + public OAuth2SessionProvider(FBMClient client, int maxCacheItems, IOauthSessionIdFactory idFactory, DbContextOptions dbCtx) + : base(client, maxCacheItems) + { + factory = idFactory; + TokenStore = new(dbCtx); + } + + ///<inheritdoc/> + protected override RemoteSession SessionCtor(string sessionId) => new OAuth2Session(sessionId, Client, BackgroundTimeout, InvlidatateCache); + + private void InvlidatateCache(OAuth2Session session) + { + lock (CacheLock) + { + _ = CacheTable.Remove(session.SessionID); + } + } + + ///<inheritdoc/> + public async ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken) + { + //Callback to close the session when the handle is closeed + static ValueTask HandleClosedAsync(ISession session, IHttpEvent entity) + { + return ((SessionBase)session).UpdateAndRelease(true, entity); + } + try + { + //Get session id + if (!factory.TryGetSessionId(entity, out string? sessionId)) + { + //Id not allowed/found, so do not attach a session + return SessionHandle.Empty; + } + + //Recover the session + RemoteSession session = await base.GetSessionAsync(entity, sessionId, cancellationToken); + + //Session should not be new + if (session.IsNew) + { + //Invalidate the session, so it is deleted + session.Invalidate(); + await session.UpdateAndRelease(true, entity); + return SessionHandle.Empty; + } + //Make sure session is still valid + if (session.Created.Add(factory.SessionValidFor) < DateTimeOffset.UtcNow) + { + //Invalidate the handle + session.Invalidate(); + //Flush changes + await session.UpdateAndRelease(false, entity); + //Remove the token from the db backing store + await TokenStore.RevokeTokenAsync(sessionId, cancellationToken); + //close entity + entity.CloseResponseError(HttpStatusCode.Unauthorized, ErrorType.InvalidToken, "The token has expired"); + //return a completed handle + return NotFoundHandle; + } + + return new SessionHandle(session, HandleClosedAsync); + } + //Pass session exceptions + catch (SessionException) + { + throw; + } + catch (Exception ex) + { + throw new SessionException("Exception raised while retreiving or loading OAuth2 session", ex); + } + } + ///<inheritdoc/> + async Task<IOAuth2TokenResult?> ITokenManager.CreateAccessTokenAsync(IHttpEvent ev, UserApplication app, CancellationToken cancellation) + { + //Get a new session for the current connection + TokenAndSessionIdResult ids = factory.GenerateTokensAndId(); + //try to insert token into the store, may fail if max has been reached + if (await TokenStore.InsertTokenAsync(ids.SessionId, app.Id, ids.RefreshToken, factory.MaxTokensPerApp, cancellation) != ERRNO.SUCCESS) + { + return null; + } + //Create new session from the session id + RemoteSession session = SessionCtor(ids.SessionId); + await session.WaitAndLoadAsync(ev, cancellation); + try + { + //Init new session + factory.InitNewSession(session, app, ev); + } + finally + { + await session.UpdateAndRelease(false, ev); + } + //Init new token result to pass to client + return new OAuth2TokenResult() + { + ExpiresSeconds = (int)factory.SessionValidFor.TotalSeconds, + TokenType = factory.TokenType, + //Return token and refresh token + AccessToken = ids.AccessToken, + RefreshToken = ids.RefreshToken, + }; + } + ///<inheritdoc/> + Task ITokenManager.RevokeTokensAsync(IReadOnlyCollection<string> tokens, CancellationToken cancellation) + { + throw new NotImplementedException(); + } + ///<inheritdoc/> + Task ITokenManager.RevokeTokensForAppAsync(string appId, CancellationToken cancellation) + { + throw new NotImplementedException(); + } + + async Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) + { + //Calculate valid token time + DateTimeOffset validAfter = DateTimeOffset.UtcNow.Subtract(factory.SessionValidFor); + //Remove tokens from db store + IReadOnlyCollection<ActiveToken> revoked = await TokenStore.CleanupExpiredTokensAsync(validAfter, cancellationToken); + //exception list + List<Exception>? errors = null; + //Remove all sessions from the store + foreach (ActiveToken token in revoked) + { + try + { + //Remove tokens by thier object id from cache + await base.Client.DeleteObjectAsync(token.Id, cancellationToken); + } + //Ignore if the object has already been removed + catch (ObjectNotFoundException) + {} + catch (Exception ex) + { + errors ??= new(); + errors.Add(ex); + } + } + if (errors?.Count > 0) + { + throw new AggregateException(errors); + } + if(revoked.Count > 0) + { + log.Debug("Cleaned up {0} expired tokens", revoked.Count); + } + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2TokenResult.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2TokenResult.cs new file mode 100644 index 0000000..1bff743 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2TokenResult.cs @@ -0,0 +1,13 @@ +using VNLib.Plugins.Essentials.Oauth; + +namespace VNLib.Plugins.Essentials.Sessions.OAuth +{ + internal class OAuth2TokenResult : IOAuth2TokenResult + { + public string? IdentityToken { get; } + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public string? TokenType { get; set; } + public int ExpiresSeconds { get; set; } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OauthTokenResponseMessage.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OauthTokenResponseMessage.cs new file mode 100644 index 0000000..c80891e --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OauthTokenResponseMessage.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Sessions.OAuth +{ + public sealed class OauthTokenResponseMessage + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + [JsonPropertyName("expires_in")] + public int Expires { get; set; } + [JsonPropertyName("id_token")] + public string? IdToken { get; set; } + } +}
\ No newline at end of file diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/TokenAndSessionIdResult.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/TokenAndSessionIdResult.cs new file mode 100644 index 0000000..f8d381f --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/TokenAndSessionIdResult.cs @@ -0,0 +1,20 @@ +#nullable enable + +using VNLib; + +namespace VNLib.Plugins.Essentials.Sessions.OAuth +{ + public readonly struct TokenAndSessionIdResult + { + public readonly string SessionId; + public readonly string AccessToken; + public readonly string? RefreshToken; + + public TokenAndSessionIdResult(string sessionId, string token, string? refreshToken) + { + SessionId = sessionId; + AccessToken = token; + RefreshToken = refreshToken; + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/VNLib.Plugins.Essentials.Sessions.OAuth.csproj b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/VNLib.Plugins.Essentials.Sessions.OAuth.csproj new file mode 100644 index 0000000..cba7822 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/VNLib.Plugins.Essentials.Sessions.OAuth.csproj @@ -0,0 +1,28 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <PlatformTarget>x64</PlatformTarget> + <GenerateDocumentationFile>False</GenerateDocumentationFile> + <Authors>Vaughn Nugent</Authors> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + + <EnableDynamicLoading>true</EnableDynamicLoading> + + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading.Sql\VNLib.Plugins.Extensions.Loading.Sql.csproj" /> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Validation\VNLib.Plugins.Extensions.Validation.csproj" /> + <ProjectReference Include="..\..\..\Oauth Plugins\VNLib.Plugins.Essentials.Oauth\VNLib.Plugins.Essentials.Oauth.csproj" /> + <ProjectReference Include="..\..\Libs\VNLib.Plugins.Sessions.Cache.Client\VNLib.Plugins.Sessions.Cache.Client.csproj" /> + <ProjectReference Include="..\VNLib.Plugins.Essentials.Sessions.Runtime\VNLib.Plugins.Essentials.Sessions.Runtime.csproj" /> + </ItemGroup> + + <Target Name="PostBuild" AfterTargets="PostBuildEvent"> + <Exec Command="start xcopy "$(TargetDir)" "F:\Programming\Web Plugins\DevPlugins\SessionProviders\$(TargetName)" /E /Y /R" /> + </Target> + +</Project> diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/IRuntimeSessionProvider.cs b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/IRuntimeSessionProvider.cs new file mode 100644 index 0000000..9941e6a --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/IRuntimeSessionProvider.cs @@ -0,0 +1,26 @@ + +using VNLib.Net.Http; +using VNLib.Utils.Logging; + +namespace VNLib.Plugins.Essentials.Sessions +{ + /// <summary> + /// Represents a dynamically loadable type that an provide sessions to http connections + /// </summary> + public interface IRuntimeSessionProvider : ISessionProvider + { + /// <summary> + /// Called immediatly after the plugin is loaded into the appdomain + /// </summary> + /// <param name="plugin">The plugin instance that is loading the module</param> + /// <param name="localizedLog">The localized log provider for the provider</param> + void Load(PluginBase plugin, ILogProvider localizedLog); + + /// <summary> + /// Determines if the provider can return a session for the connection + /// </summary> + /// <param name="entity">The entity to process</param> + /// <returns>A value indicating if this provider should be called to load a session for</returns> + bool CanProcess(IHttpEvent entity); + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/ISessionIdFactory.cs b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/ISessionIdFactory.cs new file mode 100644 index 0000000..1e88e5c --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/ISessionIdFactory.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +using VNLib.Net.Http; + +namespace VNLib.Plugins.Essentials.Sessions +{ + public interface ISessionIdFactory + { + /// <summary> + /// Attempts to recover a session-id from the connection + /// </summary> + /// <param name="entity">The connection to process</param> + /// <param name="sessionId"></param> + /// <returns></returns> + bool TryGetSessionId(IHttpEvent entity, [NotNullWhen(true)] out string? sessionId); + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VNLib.Plugins.Essentials.Sessions.Runtime.csproj b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VNLib.Plugins.Essentials.Sessions.Runtime.csproj new file mode 100644 index 0000000..5924b93 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VNLib.Plugins.Essentials.Sessions.Runtime.csproj @@ -0,0 +1,40 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <Platforms>AnyCPU;x64</Platforms> + <Authors>Vaughn Nugent</Authors> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + <Version>1.0.0.1</Version> + <PlatformTarget>x64</PlatformTarget> + <SignAssembly>False</SignAssembly> + <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> + <Deterministic>True</Deterministic> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <Deterministic>True</Deterministic> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'"> + <Deterministic>False</Deterministic> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <Deterministic>False</Deterministic> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\..\..\VNLib\Http\VNLib.Net.Http.csproj" /> + <ProjectReference Include="..\..\..\DataCaching\VNLib.Data.Caching.Extensions\VNLib.Data.Caching.Extensions.csproj" /> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading\VNLib.Plugins.Extensions.Loading.csproj" /> + <ProjectReference Include="..\..\..\PluginBase\VNLib.Plugins.PluginBase.csproj" /> + </ItemGroup> + +</Project> diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VnCacheClient.cs b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VnCacheClient.cs new file mode 100644 index 0000000..a6979ef --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VnCacheClient.cs @@ -0,0 +1,162 @@ +using System; +using System.Text.Json; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Security.Cryptography; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Data.Caching.Extensions; +using VNLib.Net.Messaging.FBM.Client; +using VNLib.Plugins.Extensions.Loading; + +namespace VNLib.Plugins.Essentials.Sessions +{ + /// <summary> + /// A wrapper to simplify a cache client object + /// </summary> + public sealed class VnCacheClient : OpenResourceHandle<FBMClient?> + { + FBMClient? _client; + + /// <summary> + /// The wrapped client + /// </summary> + public override FBMClient? Resource => _client; + + + private TimeSpan RetryInterval; + + private readonly ILogProvider? DebugLog; + private readonly IUnmangedHeap? ClientHeap; + + /// <summary> + /// Initializes an emtpy client wrapper that still requires + /// configuration loading + /// </summary> + /// <param name="debugLog">An optional debugging log</param> + /// <param name="heap">An optional <see cref="IUnmangedHeap"/> for <see cref="FBMClient"/> buffers</param> + public VnCacheClient(ILogProvider? debugLog, IUnmangedHeap? heap = null) + { + DebugLog = debugLog; + //Default to 10 seconds + RetryInterval = TimeSpan.FromSeconds(10); + + ClientHeap = heap; + } + + protected override void Free() + { + _client?.Dispose(); + _client = null; + } + + /// <summary> + /// Loads required configuration variables from the config store and + /// intializes the interal client + /// </summary> + /// <param name="config">A dictionary of configuration varables</param> + /// <exception cref="KeyNotFoundException"></exception> + public async Task LoadConfigAsync(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config) + { + int maxMessageSize = config["max_message_size"].GetInt32(); + string? brokerAddress = config["broker_address"].GetString() ?? throw new KeyNotFoundException("Missing required configuration variable broker_address"); + + //Get keys async + Task<string?> clientPrivTask = pbase.TryGetSecretAsync("client_private_key"); + Task<string?> brokerPubTask = pbase.TryGetSecretAsync("broker_public_key"); + + //Wait for all tasks to complete + string?[] keys = await Task.WhenAll(clientPrivTask, brokerPubTask); + + byte[] privKey = Convert.FromBase64String(keys[0] ?? throw new KeyNotFoundException("Missing required secret client_private_key")); + byte[] brokerPub = Convert.FromBase64String(keys[1] ?? throw new KeyNotFoundException("Missing required secret broker_public_key")); + + RetryInterval = config["retry_interval_sec"].GetTimeSpan(TimeParseType.Seconds); + + Uri brokerUri = new(brokerAddress); + + //Init the client with default settings + FBMClientConfig conf = FBMDataCacheExtensions.GetDefaultConfig(ClientHeap ?? Memory.Shared, maxMessageSize, DebugLog); + + _client = new(conf); + //Add the configuration + _client.UseBroker(brokerUri) + .ImportBrokerPublicKey(brokerPub) + .ImportClientPrivateKey(privKey) + .UseTls(brokerUri.Scheme == Uri.UriSchemeHttps); + + //Zero the key memory + Memory.InitializeBlock(privKey.AsSpan()); + Memory.InitializeBlock(brokerPub.AsSpan()); + } + + /// <summary> + /// Discovers nodes in the configured cluster and connects to a random node + /// </summary> + /// <param name="Log">A <see cref="ILogProvider"/> to write log events to</param> + /// <param name="cancellationToken">A token to cancel the operation</param> + /// <returns>A task that completes when the operation has been cancelled or an unrecoverable error occured</returns> + /// <exception cref="InvalidOperationException"></exception> + /// <exception cref="OperationCanceledException"></exception> + public async Task RunAsync(ILogProvider Log, CancellationToken cancellationToken) + { + _ = Resource ?? throw new InvalidOperationException("Client configuration not loaded, cannot connect to cache servers"); + + while (true) + { + //Load the server list + ActiveServer[]? servers; + while (true) + { + try + { + Log.Debug("Discovering cluster nodes in broker"); + //Get server list + servers = await Resource.DiscoverNodesAsync(cancellationToken); + break; + } + catch (HttpRequestException re) when (re.InnerException is SocketException) + { + Log.Warn("Broker server is unreachable"); + } + catch (Exception ex) + { + Log.Warn("Failed to get server list from broker, reason {r}", ex.Message); + } + //Gen random ms delay + int randomMsDelay = RandomNumberGenerator.GetInt32(1000, 2000); + await Task.Delay(randomMsDelay, cancellationToken); + } + if (servers?.Length == 0) + { + Log.Warn("No cluster nodes found, retrying"); + await Task.Delay(RetryInterval, cancellationToken); + continue; + } + //select random server from the list of servers + ActiveServer selected = servers!.SelectRandom(); + try + { + Log.Debug("Connecting to server {server}", selected.ServerId); + //Try to connect to server + await Resource.ConnectAndWaitForExitAsync(selected, cancellationToken); + Log.Debug("Cache server disconnected"); + } + catch (WebSocketException wse) + { + Log.Warn("Failed to connect to cache server {reason}", wse.Message); + continue; + } + catch (HttpRequestException he) when (he.InnerException is SocketException) + { + Log.Debug("Failed to connect to recommended server {server}", selected.ServerId); + //Continue next loop + continue; + } + } + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/IWebSessionIdFactory.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/IWebSessionIdFactory.cs new file mode 100644 index 0000000..81f44b8 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/IWebSessionIdFactory.cs @@ -0,0 +1,26 @@ + +using VNLib.Net.Http; + +namespace VNLib.Plugins.Essentials.Sessions.VNCache +{ + /// <summary> + /// Id factory for <see cref="WebSessionProvider"/> + /// </summary> + internal interface IWebSessionIdFactory: ISessionIdFactory + { + /// <summary> + /// The maxium amount of time a session is valid for. Sessions will be invalidated + /// after this time + /// </summary> + TimeSpan ValidFor { get; } + + /// <summary> + /// Gets a new session-id for the connection and manipulates the entity as necessary + /// </summary> + /// <param name="entity">The connection to generate the new session for</param> + /// <returns>The new session-id</returns> + string GenerateSessionId(IHttpEvent entity); + } + + +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/VNLib.Plugins.Essentials.Sessions.VNCache.csproj b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/VNLib.Plugins.Essentials.Sessions.VNCache.csproj new file mode 100644 index 0000000..ecb80b1 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/VNLib.Plugins.Essentials.Sessions.VNCache.csproj @@ -0,0 +1,47 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <Platforms>AnyCPU;x64</Platforms> + <Authors>Vaughn Nugent</Authors> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + <Version>1.0.0.1</Version> + <PlatformTarget>x64</PlatformTarget> + <SignAssembly>False</SignAssembly> + <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl> + + <EnableDynamicLoading>true</EnableDynamicLoading> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> + <Deterministic>True</Deterministic> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <Deterministic>True</Deterministic> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'"> + <Deterministic>False</Deterministic> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <Deterministic>False</Deterministic> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\VNLib.Plugins.Essentials.Sessions.Runtime\VNLib.Plugins.Essentials.Sessions.Runtime.csproj" /> + <ProjectReference Include="..\VNLib.Plugins.Sessions.Cache.Client\VNLib.Plugins.Sessions.Cache.Client.csproj" /> + </ItemGroup> + + <ItemGroup> + <Folder Include="Endpoints\" /> + </ItemGroup> + + <Target Name="PostBuild" AfterTargets="PostBuildEvent"> + <Exec Command="start xcopy "$(TargetDir)" "F:\Programming\Web Plugins\DevPlugins\SessionProviders\$(TargetName)" /E /Y /R" /> + </Target> + +</Project> diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSession.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSession.cs new file mode 100644 index 0000000..a7a6f5e --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSession.cs @@ -0,0 +1,103 @@ +using VNLib.Net.Http; +using VNLib.Data.Caching; +using VNLib.Net.Messaging.FBM.Client; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Sessions.Cache.Client; +using static VNLib.Plugins.Essentials.Sessions.ISessionExtensions; + + +namespace VNLib.Plugins.Essentials.Sessions.VNCache +{ + internal class WebSession : RemoteSession + { + protected const ulong UPGRADE_MSK = 0b0000000000010000UL; + + protected readonly Func<IHttpEvent, string, string> UpdateId; + private string? _oldId; + + public WebSession(string sessionId, FBMClient client, TimeSpan backgroundTimeOut, Func<IHttpEvent, string, string> UpdateId) + : base(sessionId, client, backgroundTimeOut) + { + this.UpdateId = UpdateId; + } + + protected override void IndexerSet(string key, string value) + { + //Set value + base.IndexerSet(key, value); + switch (key) + { + //Set the upgrade flag when token data is modified + case LOGIN_TOKEN_ENTRY: + case TOKEN_ENTRY: + Flags.Set(UPGRADE_MSK); + break; + } + } + + public override async Task WaitAndLoadAsync(IHttpEvent entity, CancellationToken cancellationToken) + { + //Wait for the session to load + await base.WaitAndLoadAsync(entity, cancellationToken); + //If the session is new, set to web mode + if (IsNew) + { + SessionType = SessionType.Web; + } + } + + private async Task ProcessUpgradeAsync() + { + //Setup timeout cancellation for the update, to cancel it + using CancellationTokenSource cts = new(UpdateTimeout); + await Client.AddOrUpdateObjectAsync(_oldId!, SessionID, DataStore, cts.Token); + _oldId = null; + } + + protected override ValueTask<Task?> UpdateResource(bool isAsync, IHttpEvent state) + { + Task? result = null; + //Check flags in priority level, Invalid is highest state priority + if (Flags.IsSet(INVALID_MSK)) + { + //Clear all stored values + DataStore!.Clear(); + //Reset ip-address + UserIP = state.Server.GetTrustedIp(); + //Update created time + Created = DateTimeOffset.UtcNow; + //Init the new session-data + this.InitNewSession(state.Server); + //Restore session type + SessionType = SessionType.Web; + //generate new session-id and update the record in the store + _oldId = SessionID; + //Update the session-id + SessionID = UpdateId(state, _oldId); + //write update to server + result = Task.Run(ProcessUpgradeAsync); + } + else if (Flags.IsSet(UPGRADE_MSK | REGEN_ID_MSK)) + { + //generate new session-id and update the record in the store + _oldId = SessionID; + //Update the session-id + SessionID = UpdateId(state, _oldId); + //Update created time + Created = DateTimeOffset.UtcNow; + //write update to server + result = Task.Run(ProcessUpgradeAsync); + } + else if (Flags.IsSet(MODIFIED_MSK)) + { + //Send update to server + result = Task.Run(ProcessUpdateAsync); + } + + //Clear all flags + Flags.ClearAll(); + + return ValueTask.FromResult<Task?>(null); + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionIdFactoryImpl.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionIdFactoryImpl.cs new file mode 100644 index 0000000..001723a --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionIdFactoryImpl.cs @@ -0,0 +1,96 @@ +using System.Diagnostics.CodeAnalysis; + +using VNLib.Hashing; +using VNLib.Net.Http; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Extensions; + + +namespace VNLib.Plugins.Essentials.Sessions.VNCache +{ + /// <summary> + /// <see cref="IWebSessionIdFactory"/> implementation, using + /// http cookies as session id storage + /// </summary> + internal sealed class WebSessionIdFactoryImpl : IWebSessionIdFactory + { + public TimeSpan ValidFor { get; } + + public string GenerateSessionId(IHttpEvent entity) + { + //Random hex hash + string cookie = RandomHash.GetRandomBase32(_tokenSize); + + //Set the session id cookie + entity.Server.SetCookie(SessionCookieName, cookie, ValidFor, secure: true, httpOnly: true); + + //return session-id value from cookie value + return ComputeSessionIdFromCookie(cookie); + } + + bool ISessionIdFactory.TryGetSessionId(IHttpEvent entity, [NotNullWhen(true)] out string? sessionId) + { + //Get authorization token and make sure its not too large to cause a buffer overflow + if (entity.Server.GetCookie(SessionCookieName, out string? cookie) && (cookie.Length + SessionIdPrefix.Length) <= _bufferSize) + { + //Compute session id from token + sessionId = ComputeSessionIdFromCookie(cookie); + + return true; + } + //Only add sessions for user-agents + else if(entity.Server.IsBrowser()) + { + //Get a new session id + sessionId = GenerateSessionId(entity); + + return true; + } + else + { + sessionId = null; + return false; + } + } + + private readonly string SessionCookieName; + private readonly string SessionIdPrefix; + private readonly int _bufferSize; + private readonly int _tokenSize; + + /// <summary> + /// Initialzies a new web session Id factory + /// </summary> + /// <param name="cookieSize">The size of the cookie in bytes</param> + /// <param name="sessionCookieName">The name of the session cookie</param> + /// <param name="sessionIdPrefix">The session-id internal prefix</param> + /// <param name="validFor">The time the session cookie is valid for</param> + public WebSessionIdFactoryImpl(uint cookieSize, string sessionCookieName, string sessionIdPrefix, TimeSpan validFor) + { + ValidFor = validFor; + SessionCookieName = sessionCookieName; + SessionIdPrefix = sessionIdPrefix; + _tokenSize = (int)cookieSize; + //Calc buffer size + _bufferSize = Math.Max(32, ((int)cookieSize * 3) + sessionIdPrefix.Length); + } + + + private string ComputeSessionIdFromCookie(string sessionId) + { + //Buffer to copy data to + using UnsafeMemoryHandle<char> buffer = Memory.UnsafeAlloc<char>(_bufferSize, true); + + //Writer to accumulate data + ForwardOnlyWriter<char> writer = new(buffer.Span); + + //Append prefix and session id + writer.Append(SessionIdPrefix); + writer.Append(sessionId); + + //Compute base64 hash of token and + return ManagedHash.ComputeBase64Hash(writer.AsSpan(), HashAlg.SHA256); + } + } +}
\ No newline at end of file diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProvider.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProvider.cs new file mode 100644 index 0000000..fd725bf --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProvider.cs @@ -0,0 +1,122 @@ +using System; + +using VNLib.Net.Http; +using VNLib.Net.Messaging.FBM.Client; +using VNLib.Plugins.Sessions.Cache.Client; + +namespace VNLib.Plugins.Essentials.Sessions.VNCache +{ + /// <summary> + /// The implementation of a VNCache web based session + /// </summary> + internal sealed class WebSessionProvider : SessionCacheClient, ISessionProvider + { + static readonly TimeSpan BackgroundUpdateTimeout = TimeSpan.FromSeconds(10); + + private readonly IWebSessionIdFactory factory; + private readonly uint MaxConnections; + + /// <summary> + /// Initializes a new <see cref="WebSessionProvider"/> + /// </summary> + /// <param name="client">The cache client to make cache operations against</param> + /// <param name="maxCacheItems">The max number of items to store in cache</param> + /// <param name="maxWaiting">The maxium number of waiting session events before 503s are sent</param> + /// <param name="factory">The session-id factory</param> + public WebSessionProvider(FBMClient client, int maxCacheItems, uint maxWaiting, IWebSessionIdFactory factory) : base(client, maxCacheItems) + { + this.factory = factory; + MaxConnections = maxWaiting; + } + + private string UpdateSessionId(IHttpEvent entity, string oldId) + { + //Generate and set a new sessionid + string newid = factory.GenerateSessionId(entity); + //Aquire lock on cache + lock (CacheLock) + { + //Change the cache lookup id + if (CacheTable.Remove(oldId, out RemoteSession? session)) + { + CacheTable.Add(newid, session); + } + } + return newid; + } + + protected override RemoteSession SessionCtor(string sessionId) => new WebSession(sessionId, Client, BackgroundUpdateTimeout, UpdateSessionId); + + + private uint _waitingCount; + + public async ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken) + { + //Callback to close the session when the handle is closeed + static ValueTask HandleClosedAsync(ISession session, IHttpEvent entity) + { + return (session as SessionBase)!.UpdateAndRelease(true, entity); + } + + try + { + //Get session id + if (!factory.TryGetSessionId(entity, out string? sessionId)) + { + //Id not allowed/found, so do not attach a session + return SessionHandle.Empty; + } + + //Limit max number of waiting clients + if (_waitingCount > MaxConnections) + { + //Set 503 for temporary unavail + entity.CloseResponse(System.Net.HttpStatusCode.ServiceUnavailable); + return new SessionHandle(null, FileProcessArgs.VirtualSkip, null); + } + + RemoteSession session; + + //Inc waiting count + Interlocked.Increment(ref _waitingCount); + try + { + //Recover the session + session = await GetSessionAsync(entity, sessionId, cancellationToken); + } + finally + { + //Dec on exit + Interlocked.Decrement(ref _waitingCount); + } + + //If the session is new (not in cache), then overwrite the session id with a new one as user may have specified their own + if (session.IsNew) + { + session.RegenID(); + } + + //Make sure the session has not expired yet + if (session.Created.Add(factory.ValidFor) < DateTimeOffset.UtcNow) + { + //Invalidate the session, so its technically valid for this request, but will be cleared on this handle close cycle + session.Invalidate(); + //Clear basic login status + session.Token = null; + session.UserID = null; + session.Privilages = 0; + session.SetLoginToken(null); + } + return new SessionHandle(session, HandleClosedAsync); + } + catch (SessionException) + { + throw; + } + catch (Exception ex) + { + throw new SessionException("Exception raised while retreiving or loading Web session", ex); + } + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProviderEntry.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProviderEntry.cs new file mode 100644 index 0000000..f72d1c5 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProviderEntry.cs @@ -0,0 +1,101 @@ +using System.Text.Json; + +using VNLib.Net.Http; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Configuration; + +namespace VNLib.Plugins.Essentials.Sessions.VNCache +{ + public sealed class WebSessionProviderEntry : IRuntimeSessionProvider + { + const string VNCACHE_CONFIG_KEY = "vncache"; + const string WEB_SESSION_CONFIG = "web"; + + private WebSessionProvider? _sessions; + + public bool CanProcess(IHttpEvent entity) + { + //Web sessions can always be provided so long as cache is loaded + return _sessions != null; + } + + public ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken) + { + return _sessions!.GetSessionAsync(entity, cancellationToken); + } + + void IRuntimeSessionProvider.Load(PluginBase plugin, ILogProvider localized) + { + //Try get vncache config element + IReadOnlyDictionary<string, JsonElement> cacheConfig = plugin.GetConfig(VNCACHE_CONFIG_KEY); + + IReadOnlyDictionary<string, JsonElement> webSessionConfig = plugin.GetConfig(WEB_SESSION_CONFIG); + + uint cookieSize = webSessionConfig["cookie_size"].GetUInt32(); + string cookieName = webSessionConfig["cookie_name"].GetString() ?? throw new KeyNotFoundException($"Missing required element 'cookie_name' for config '{WEB_SESSION_CONFIG}'"); + string cachePrefix = webSessionConfig["cache_prefix"].GetString() ?? throw new KeyNotFoundException($"Missing required element 'cache_prefix' for config '{WEB_SESSION_CONFIG}'"); + TimeSpan validFor = webSessionConfig["valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); + + + //Init id factory + WebSessionIdFactoryImpl idFactory = new(cookieSize, cookieName, cachePrefix, validFor); + + //Run client connection + _ = WokerDoWorkAsync(plugin, localized, idFactory, cacheConfig, webSessionConfig); + } + + + /* + * Starts and monitors the VNCache connection + */ + + private async Task WokerDoWorkAsync( + PluginBase plugin, + ILogProvider localized, + WebSessionIdFactoryImpl idFactory, + IReadOnlyDictionary<string, JsonElement> cacheConfig, + IReadOnlyDictionary<string, JsonElement> webSessionConfig) + { + //Init cache client + using VnCacheClient cache = new(plugin.IsDebug() ? plugin.Log : null, Memory.Shared); + + try + { + int cacheLimit = (int)webSessionConfig["cache_size"].GetUInt32(); + uint maxConnections = webSessionConfig["max_waiting_connections"].GetUInt32(); + + //Try loading config + await cache.LoadConfigAsync(plugin, cacheConfig); + + //Init provider + _sessions = new(cache.Resource!, cacheLimit, maxConnections, idFactory); + + + localized.Information("Session provider loaded"); + + //Run and wait for exit + await cache.RunAsync(localized, plugin.UnloadToken); + + } + catch (OperationCanceledException) + { } + catch (KeyNotFoundException e) + { + localized.Error("Missing required configuration variable for VnCache client: {0}", e.Message); + } + catch (Exception ex) + { + localized.Error(ex, "Cache client error occured in session provider"); + } + finally + { + _sessions = null; + } + + localized.Information("Cache client exited"); + } + } +}
\ No newline at end of file diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/MemorySession.cs b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySession.cs new file mode 100644 index 0000000..35e2fea --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySession.cs @@ -0,0 +1,107 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using System.Collections.Generic; + +using VNLib.Net.Http; +using VNLib.Plugins.Essentials.Extensions; + +using static VNLib.Plugins.Essentials.Sessions.ISessionExtensions; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Sessions.Memory +{ + internal class MemorySession : SessionBase + { + private readonly Dictionary<string, string> DataStorage; + + private readonly MemorySessionStore SessionStore; + + public MemorySession(IPAddress ipAddress, MemorySessionStore SessionStore) + { + //Set the initial is-new flag + DataStorage = new Dictionary<string, string>(10); + this.SessionStore = SessionStore; + //Get new session id + SessionID = SessionStore.NewSessionID; + UserIP = ipAddress; + SessionType = SessionType.Web; + Created = DateTimeOffset.UtcNow; + //Init + IsNew = true; + } + //Store in memory directly + public override IPAddress UserIP { get; protected set; } + + //Session type has no backing store, so safe to hard-code it's always web + + public override SessionType SessionType => SessionType.Web; + + protected override ValueTask<Task?> UpdateResource(bool isAsync, IHttpEvent state) + { + //if invalid is set, invalide the current session + if (Flags.IsSet(INVALID_MSK)) + { + //Clear storage, and regenerate the sessionid + DataStorage.Clear(); + RegenId(state); + //Reset ip-address + UserIP = state.Server.GetTrustedIp(); + //Update created-time + Created = DateTimeOffset.UtcNow; + //Re-initialize the session to the state of the current connection + this.InitNewSession(state.Server); + //Modified flag doesnt matter since there is no write-back + + } + else if (Flags.IsSet(REGEN_ID_MSK)) + { + //Regen id without modifying the data store + RegenId(state); + } + //Clear flags + Flags.ClearAll(); + //Memory session always completes + return ValueTask.FromResult<Task?>(null); + } + + private void RegenId(IHttpEvent entity) + { + //Get a new session-id + string newId = SessionStore.NewSessionID; + //Update the cache entry + SessionStore.UpdateRecord(newId, this); + //store new sessionid + SessionID = newId; + //set cookie + SessionStore.SetSessionCookie(entity, this); + } + + protected override Task OnEvictedAsync() + { + //Clear all session data + DataStorage.Clear(); + return Task.CompletedTask; + } + + protected override string IndexerGet(string key) + { + return DataStorage.GetValueOrDefault(key, string.Empty); + } + + protected override void IndexerSet(string key, string value) + { + //Check for special keys + switch (key) + { + //For tokens/login hashes, we can set the upgrade flag + case TOKEN_ENTRY: + case LOGIN_TOKEN_ENTRY: + Flags.Set(REGEN_ID_MSK); + break; + } + DataStorage[key] = value; + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionConfig.cs b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionConfig.cs new file mode 100644 index 0000000..cbbaf53 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionConfig.cs @@ -0,0 +1,33 @@ +using System; +using VNLib.Utils.Logging; + +namespace VNLib.Net.Sessions +{ + /// <summary> + /// Represents configration variables used to create and operate http sessions. + /// </summary> + public readonly struct MemorySessionConfig + { + /// <summary> + /// The name of the cookie to use for matching sessions + /// </summary> + public string SessionCookieID { get; init; } + /// <summary> + /// The size (in bytes) of the genreated SessionIds + /// </summary> + public uint SessionIdSizeBytes { get; init; } + /// <summary> + /// The amount of time a session is valid (within the backing store) + /// </summary> + public TimeSpan SessionTimeout { get; init; } + /// <summary> + /// The log for which all errors within the <see cref="SessionProvider"/> instance will be written to. + /// </summary> + public ILogProvider SessionLog { get; init; } + /// <summary> + /// The maximum number of sessions allowed to be cached in memory. If this value is exceed requests to this + /// server will be denied with a 503 error code + /// </summary> + public int MaxAllowedSessions { get; init; } + } +}
\ No newline at end of file diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionEntrypoint.cs b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionEntrypoint.cs new file mode 100644 index 0000000..f41d384 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionEntrypoint.cs @@ -0,0 +1,64 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + + +using VNLib.Net.Http; +using VNLib.Net.Sessions; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Extensions.Loading.Configuration; +using VNLib.Plugins.Extensions.Loading.Events; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Sessions.Memory +{ + public sealed class MemorySessionEntrypoint : IRuntimeSessionProvider, IIntervalScheduleable + { + const string WEB_SESSION_CONFIG = "web"; + + private MemorySessionStore? _sessions; + + bool IRuntimeSessionProvider.CanProcess(IHttpEvent entity) + { + //Web sessions can always be provided + return _sessions != null; + } + + public ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken) + { + return _sessions!.GetSessionAsync(entity, cancellationToken); + } + + void IRuntimeSessionProvider.Load(PluginBase plugin, ILogProvider localized) + { + //Get websessions config element + + IReadOnlyDictionary<string, JsonElement> webSessionConfig = plugin.GetConfig(WEB_SESSION_CONFIG); + + MemorySessionConfig config = new() + { + SessionLog = localized, + MaxAllowedSessions = webSessionConfig["cache_size"].GetInt32(), + SessionIdSizeBytes = webSessionConfig["cookie_size"].GetUInt32(), + SessionTimeout = webSessionConfig["valid_for_sec"].GetTimeSpan(TimeParseType.Seconds), + SessionCookieID = webSessionConfig["cookie_name"].GetString() ?? throw new KeyNotFoundException($"Missing required element 'cookie_name' for config '{WEB_SESSION_CONFIG}'"), + }; + + _sessions = new(config); + + //Schedule garbage collector + _ = plugin.ScheduleInterval(this, TimeSpan.FromMinutes(1)); + } + + Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) + { + //Cleanup expired sessions on interval + _sessions?.GC(); + return Task.CompletedTask; + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionStore.cs b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionStore.cs new file mode 100644 index 0000000..15c3002 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionStore.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +using VNLib.Hashing; +using VNLib.Net.Http; +using VNLib.Net.Sessions; +using VNLib.Utils; +using VNLib.Utils.Async; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Extensions; + + +namespace VNLib.Plugins.Essentials.Sessions.Memory +{ + + /// <summary> + /// An <see cref="ISessionProvider"/> for in-process-memory backed sessions + /// </summary> + internal sealed class MemorySessionStore : ISessionProvider + { + private readonly Dictionary<string, MemorySession> SessionsStore; + + internal readonly MemorySessionConfig Config; + + public MemorySessionStore(MemorySessionConfig config) + { + Config = config; + SessionsStore = new(config.MaxAllowedSessions, StringComparer.Ordinal); + } + + ///<inheritdoc/> + public async ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken) + { + + static ValueTask SessionHandleClosedAsync(ISession session, IHttpEvent ev) + { + return (session as MemorySession).UpdateAndRelease(true, ev); + } + + //Check for previous session cookie + if (entity.Server.RequestCookies.TryGetNonEmptyValue(Config.SessionCookieID, out string sessionId)) + { + //Try to get the old record or evict it + ERRNO result = SessionsStore.TryGetOrEvictRecord(sessionId, out MemorySession session); + if(result > 0) + { + //Valid, now wait for exclusive access + await session.WaitOneAsync(cancellationToken); + return new (session, SessionHandleClosedAsync); + } + //Continue creating a new session + } + + //Dont service non browsers for new sessions + if (!entity.Server.IsBrowser()) + { + return SessionHandle.Empty; + } + + //try to cleanup expired records + SessionsStore.CollectRecords(); + //Make sure there is enough room to add a new session + if (SessionsStore.Count >= Config.MaxAllowedSessions) + { + entity.Server.SetNoCache(); + //Set 503 when full + entity.CloseResponse(System.Net.HttpStatusCode.ServiceUnavailable); + //Cannot service new session + return new(null, FileProcessArgs.VirtualSkip, null); + } + //Initialze a new session + MemorySession ms = new(entity.Server.GetTrustedIp(), this); + //Set session cookie + SetSessionCookie(entity, ms); + //Increment the semaphore + (ms as IWaitHandle).WaitOne(); + //store the session in cache while holding semaphore, and set its expiration + SessionsStore.StoreRecord(ms.SessionID, ms, Config.SessionTimeout); + //Init new session handle + return new SessionHandle(ms, SessionHandleClosedAsync); + } + + /// <summary> + /// Gets a new unique sessionid for sessions + /// </summary> + internal string NewSessionID => RandomHash.GetRandomHex((int)Config.SessionIdSizeBytes); + + internal void UpdateRecord(string newSessId, MemorySession session) + { + lock (SessionsStore) + { + //Remove old record from the store + SessionsStore.Remove(session.SessionID); + //Insert the new session + SessionsStore.Add(newSessId, session); + } + } + /// <summary> + /// Sets a standard session cookie for an entity/connection + /// </summary> + /// <param name="entity">The entity to set the cookie on</param> + /// <param name="session">The session attached to the </param> + internal void SetSessionCookie(IHttpEvent entity, MemorySession session) + { + //Set session cookie + entity.Server.SetCookie(Config.SessionCookieID, session.SessionID, null, "/", Config.SessionTimeout, CookieSameSite.Lax, true, true); + } + /// <summary> + /// Evicts all sessions from the current store + /// </summary> + public void Cleanup() + { + //Expire all old records to cleanup all entires + this.SessionsStore.CollectRecords(DateTime.MaxValue); + } + /// <summary> + /// Collects all expired records from the current store + /// </summary> + public void GC() + { + //collect expired records + this.SessionsStore.CollectRecords(); + } + } +} diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.csproj b/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.csproj new file mode 100644 index 0000000..0d21b31 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.csproj @@ -0,0 +1,43 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <Platforms>AnyCPU;x64</Platforms> + + <EnableDynamicLoading>true</EnableDynamicLoading> + </PropertyGroup> + + <!-- Resolve nuget dll files and store them in the output dir --> + <PropertyGroup> + <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> + <AssemblyName>VNLib.Plugins.Essentials.Sessions.Memory</AssemblyName> + <RootNamespace>VNLib.Plugins.Essentials.Sessions.Memory</RootNamespace> + <Authors>Vaughn Nugent</Authors> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <DocumentationFile></DocumentationFile> + </PropertyGroup> + <ProjectExtensions><VisualStudio><UserProperties /></VisualStudio></ProjectExtensions> + <ItemGroup> + <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\..\..\VNLib\Http\VNLib.Net.Http.csproj" /> + <ProjectReference Include="..\VNLib.Plugins.Essentials.Sessions.Runtime\VNLib.Plugins.Essentials.Sessions.Runtime.csproj" /> + </ItemGroup> + <Target Name="PostBuild" AfterTargets="PostBuildEvent"> + <Exec Command="start xcopy "$(TargetDir)" "F:\Programming\Web Plugins\DevPlugins\SessionProviders\$(TargetName)" /E /Y /R" /> + </Target> + + +</Project> diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.xml b/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.xml new file mode 100644 index 0000000..9c596c3 --- /dev/null +++ b/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.xml @@ -0,0 +1,75 @@ +<?xml version="1.0"?> +<doc> + <assembly> + <name>VNLib.Plugins.Essentials.Sessions.Memory</name> + </assembly> + <members> + <member name="T:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore"> + <summary> + An <see cref="T:VNLib.Plugins.Essentials.Sessions.ISessionProvider"/> for in-process-memory backed sessions + </summary> + </member> + <member name="M:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.GetSessionAsync(VNLib.Net.Http.HttpEvent,System.Threading.CancellationToken)"> + <inheritdoc/> + </member> + <member name="P:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.NewSessionID"> + <summary> + Gets a new unique sessionid for sessions + </summary> + </member> + <member name="M:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.SetSessionCookie(VNLib.Net.Http.HttpEvent,VNLib.Plugins.Essentials.Sessions.Memory.MemorySession)"> + <summary> + Sets a standard session cookie for an entity/connection + </summary> + <param name="entity">The entity to set the cookie on</param> + <param name="session">The session attached to the </param> + </member> + <member name="M:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.Cleanup"> + <summary> + Evicts all sessions from the current store + </summary> + </member> + <member name="M:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.GC"> + <summary> + Collects all expired records from the current store + </summary> + </member> + <member name="T:VNLib.Plugins.Essentials.Sessions.Memory.MemSessionHandle"> + <summary> + Provides a one-time-use handle (similar to asyncReleaser, or openHandle) + that holds exclusive access to a session until it is released + </summary> + </member> + <member name="T:VNLib.Net.Sessions.MemorySessionConfig"> + <summary> + Represents configration variables used to create and operate http sessions. + </summary> + </member> + <member name="P:VNLib.Net.Sessions.MemorySessionConfig.SessionCookieID"> + <summary> + The name of the cookie to use for matching sessions + </summary> + </member> + <member name="P:VNLib.Net.Sessions.MemorySessionConfig.SessionIdSizeBytes"> + <summary> + The size (in bytes) of the genreated SessionIds + </summary> + </member> + <member name="P:VNLib.Net.Sessions.MemorySessionConfig.SessionTimeout"> + <summary> + The amount of time a session is valid (within the backing store) + </summary> + </member> + <member name="P:VNLib.Net.Sessions.MemorySessionConfig.SessionLog"> + <summary> + The log for which all errors within the <see cref="!:SessionProvider"/> instance will be written to. + </summary> + </member> + <member name="P:VNLib.Net.Sessions.MemorySessionConfig.MaxAllowedSessions"> + <summary> + The maximum number of sessions allowed to be cached in memory. If this value is exceed requests to this + server will be denied with a 503 error code + </summary> + </member> + </members> +</doc> diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/MessageTooLargeException.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/MessageTooLargeException.cs new file mode 100644 index 0000000..15164ca --- /dev/null +++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/MessageTooLargeException.cs @@ -0,0 +1,27 @@ +using System; +using System.Runtime.Serialization; + +using VNLib.Net.Messaging.FBM; + +namespace VNLib.Plugins.Sessions.Cache.Client +{ + /// <summary> + /// Raised when a request message is too large to send to + /// the server and the server may close the connection. + /// </summary> + public class MessageTooLargeException : FBMException + { + ///<inheritdoc/> + public MessageTooLargeException() + {} + ///<inheritdoc/> + public MessageTooLargeException(string message) : base(message) + {} + ///<inheritdoc/> + public MessageTooLargeException(string message, Exception innerException) : base(message, innerException) + {} + ///<inheritdoc/> + protected MessageTooLargeException(SerializationInfo info, StreamingContext context) : base(info, context) + {} + } +} diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionStatusException.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionStatusException.cs new file mode 100644 index 0000000..5bb6f42 --- /dev/null +++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionStatusException.cs @@ -0,0 +1,19 @@ +using System; +using System.Runtime.Serialization; + +using VNLib.Plugins.Essentials.Sessions; + +namespace VNLib.Plugins.Sessions.Cache.Client +{ + public class SessionStatusException : SessionException + { + public SessionStatusException() + {} + public SessionStatusException(string message) : base(message) + {} + public SessionStatusException(string message, Exception innerException) : base(message, innerException) + {} + protected SessionStatusException(SerializationInfo info, StreamingContext context) : base(info, context) + {} + } +} diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionUpdateFailedException.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionUpdateFailedException.cs new file mode 100644 index 0000000..1b842b7 --- /dev/null +++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionUpdateFailedException.cs @@ -0,0 +1,17 @@ +using System; +using System.Runtime.Serialization; + +namespace VNLib.Plugins.Sessions.Cache.Client +{ + public class SessionUpdateFailedException : SessionStatusException + { + public SessionUpdateFailedException() + {} + public SessionUpdateFailedException(string message) : base(message) + {} + public SessionUpdateFailedException(string message, Exception innerException) : base(message, innerException) + {} + protected SessionUpdateFailedException(SerializationInfo info, StreamingContext context) : base(info, context) + {} + } +} diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/RemoteSession.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/RemoteSession.cs new file mode 100644 index 0000000..2ab27f5 --- /dev/null +++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/RemoteSession.cs @@ -0,0 +1,173 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +using Microsoft.VisualStudio.Threading; + +using VNLib.Net.Http; +using VNLib.Data.Caching; +using VNLib.Data.Caching.Exceptions; +using VNLib.Utils.Extensions; +using VNLib.Net.Messaging.FBM.Client; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Essentials.Extensions; + +#nullable enable + +namespace VNLib.Plugins.Sessions.Cache.Client +{ + /// <summary> + /// Base class for cacheable lazy initialized session entires + /// that exist in a remote caching server + /// </summary> + public abstract class RemoteSession : SessionBase + { + protected const string CREATED_TIME_ENTRY = "__.i.ctime"; + + protected readonly FBMClient Client; + protected readonly TimeSpan UpdateTimeout; + + private readonly AsyncLazyInitializer Initializer; + + /// <summary> + /// The lazy loaded data-store + /// </summary> + protected Dictionary<string, string>? DataStore; + + public RemoteSession(string sessionId, FBMClient client, TimeSpan backgroundTimeOut) + { + SessionID = sessionId; + UpdateTimeout = backgroundTimeOut; + Client = client; + Initializer = new(InitializeAsync, null); + } + + /// <summary> + /// The data initializer, loads the data store from the connected cache server + /// </summary> + /// <returns>A task that completes when the get operation completes</returns> + protected virtual async Task InitializeAsync() + { + //Setup timeout cancellation for the get, to cancel it + using CancellationTokenSource cts = new(UpdateTimeout); + //get or create a new session + DataStore = await Client.GetObjectAsync<Dictionary<string, string>>(SessionID, cancellationToken: cts.Token); + } + /// <summary> + /// Updates the current sessin agaisnt the cache store + /// </summary> + /// <returns>A task that complets when the update has completed</returns> + protected virtual async Task ProcessUpdateAsync() + { + //Setup timeout cancellation for the update, to cancel it + using CancellationTokenSource cts = new(UpdateTimeout); + await Client.AddOrUpdateObjectAsync(SessionID, null, DataStore, cts.Token); + } + /// <summary> + /// Delets the current session in the remote store + /// </summary> + /// <returns>A task that completes when instance has been deleted</returns> + protected virtual async Task ProcessDeleteAsync() + { + //Setup timeout cancellation for the update, to cancel it + using CancellationTokenSource cts = new(UpdateTimeout); + try + { + await Client.DeleteObjectAsync(SessionID, cts.Token); + } + catch (ObjectNotFoundException) + { + //This is fine, if the object does not exist, nothing to invalidate + } + } + + ///<inheritdoc/> + public override DateTimeOffset Created + { + get + { + //Deserialze the base32 ms + long unixMs = this.GetValueType<string, long>(CREATED_TIME_ENTRY); + //set created time from ms + return DateTimeOffset.FromUnixTimeMilliseconds(unixMs); + } + + protected set => this.SetValueType(CREATED_TIME_ENTRY, value.ToUnixTimeMilliseconds()); + } + ///<inheritdoc/> + protected override string IndexerGet(string key) + { + //Get the value at the key or an empty string as a default + return DataStore!.GetValueOrDefault(key, string.Empty); + } + ///<inheritdoc/> + protected override void IndexerSet(string key, string value) + { + //If the value is null, remove the key from the store + if (value == null) + { + //Set modified flag + IsModified |= DataStore!.Remove(key); + } + else + { + //Store the value at the specified key + DataStore![key] = value; + IsModified = true; + } + } + + + /* + * If the data-store is not found it means the session does not + * exist in cache, so its technically not dangerous to reuse, + * so the new mask needs to be set, but the old ID is going + * to be reused + */ + + /// <summary> + /// Waits for exclusive access to the session, and initializes + /// session data (loads it from the remote store) + /// </summary> + /// <param name="entity">The event to attach a session to</param> + /// <param name="cancellationToken">A token to cancel the operaion</param> + /// <returns></returns> + public virtual async Task WaitAndLoadAsync(IHttpEvent entity, CancellationToken cancellationToken) + { + //Wait for exclusive access + await base.WaitOneAsync(cancellationToken); + try + { + //Lazily initalize the current instance + await Initializer.InitializeAsync(cancellationToken); + //See if data-store is null (new session was created + if (DataStore == null) + { + //New session was created + DataStore = new(10); + //Set is-new flag + Flags.Set(IS_NEW_MSK); + //Set created time + Created = DateTimeOffset.UtcNow; + //Init ipaddress + UserIP = entity.Server.GetTrustedIp(); + //Set modified flag so session will be updated + IsModified = true; + } + } + catch + { + MainLock.Release(); + throw; + } + } + ///<inheritdoc/> + protected override Task OnEvictedAsync() + { + //empty the dict to help the GC + DataStore!.Clear(); + return Task.CompletedTask; + } + } +} diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/SessionCacheClient.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/SessionCacheClient.cs new file mode 100644 index 0000000..de0e370 --- /dev/null +++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/SessionCacheClient.cs @@ -0,0 +1,159 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +using VNLib.Utils; +using VNLib.Utils.Memory.Caching; +using VNLib.Net.Http; +using VNLib.Net.Messaging.FBM.Client; +using VNLib.Plugins.Essentials.Sessions; + +#nullable enable + +namespace VNLib.Plugins.Sessions.Cache.Client +{ + + /// <summary> + /// A client that allows access to sessions located on external servers + /// </summary> + public abstract class SessionCacheClient : VnDisposeable, ICacheHolder + { + public class LRUSessionStore<T> : LRUCache<string, T> where T : ISession, ICacheable + { + public override bool IsReadOnly => false; + protected override int MaxCapacity { get; } + + public LRUSessionStore(int maxCapacity) : base(StringComparer.Ordinal) => MaxCapacity = maxCapacity; + + protected override bool CacheMiss(string key, [NotNullWhen(true)] out T? value) + { + value = default; + return false; + } + protected override void Evicted(KeyValuePair<string, T> evicted) + { + //Evice record + evicted.Value.Evicted(); + } + } + + protected readonly LRUSessionStore<RemoteSession> CacheTable; + protected readonly object CacheLock; + protected readonly int MaxLoadedEntires; + + protected FBMClient Client { get; } + + /// <summary> + /// Initializes a new <see cref="SessionCacheClient"/> + /// </summary> + /// <param name="client"></param> + /// <param name="maxCacheItems">The maximum number of sessions to keep in memory</param> + public SessionCacheClient(FBMClient client, int maxCacheItems) + { + MaxLoadedEntires = maxCacheItems; + CacheLock = new(); + CacheTable = new(maxCacheItems); + Client = client; + //Listen for close events + Client.ConnectionClosed += Client_ConnectionClosed; + } + + private void Client_ConnectionClosed(object? sender, EventArgs e) => CacheHardClear(); + + /// <summary> + /// Attempts to get a session from the cache identified by its sessionId asynchronously + /// </summary> + /// <param name="entity">The connection/request to attach the session to</param> + /// <param name="sessionId">The ID of the session to retrieve</param> + /// <param name="cancellationToken">A token to cancel the operation</param> + /// <returns>A <see cref="ValueTask"/> that resolves the remote session</returns> + /// <exception cref="SessionException"></exception> + public virtual async ValueTask<RemoteSession> GetSessionAsync(IHttpEvent entity, string sessionId, CancellationToken cancellationToken) + { + Check(); + try + { + RemoteSession? session; + //Aquire lock on cache + lock (CacheLock) + { + //See if session is loaded into cache + if (!CacheTable.TryGetValue(sessionId, out session)) + { + //Init new record + session = SessionCtor(sessionId); + //Add to cache + CacheTable.Add(session.SessionID, session); + } + //Valid entry found in cache + } + try + { + //Load session-data + await session.WaitAndLoadAsync(entity, cancellationToken); + return session; + } + catch + { + //Remove the invalid cached session + lock (CacheLock) + { + _ = CacheTable.Remove(sessionId); + } + throw; + } + } + catch (SessionException) + { + throw; + } + catch (OperationCanceledException) + { + throw; + } + //Wrap exceptions + catch (Exception ex) + { + throw new SessionException("An unhandled exception was raised", ex); + } + } + + /// <summary> + /// Gets a new <see cref="RemoteSession"/> instances for the given sessionId, + /// and places it a the head of internal cache + /// </summary> + /// <param name="sessionId">The session identifier</param> + /// <returns>The new session for the given ID</returns> + protected abstract RemoteSession SessionCtor(string sessionId); + + ///<inheritdoc/> + public void CacheClear() + { + + } + ///<inheritdoc/> + public void CacheHardClear() + { + //Cleanup cache when disconnected + lock (CacheLock) + { + CacheTable.Clear(); + foreach (RemoteSession session in (IEnumerable<RemoteSession>)CacheTable) + { + session.Evicted(); + } + CacheTable.Clear(); + } + } + + protected override void Free() + { + //Unsub from events + Client.ConnectionClosed -= Client_ConnectionClosed; + //Clear all cached sessions + CacheHardClear(); + } + } +} diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.csproj b/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.csproj new file mode 100644 index 0000000..0c12cec --- /dev/null +++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.csproj @@ -0,0 +1,33 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <Platforms>AnyCPU;x64</Platforms> + <Authors>Vaughn Nugent</Authors> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + <Version>1.0.0.1</Version> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <DocumentationFile></DocumentationFile> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Microsoft.VisualStudio.Threading" Version="17.3.44" /> + <PackageReference Include="RestSharp" Version="108.0.2" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\..\DataCaching\VNLib.Data.Caching\src\VNLib.Data.Caching.csproj" /> + </ItemGroup> + +</Project> diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.xml b/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.xml new file mode 100644 index 0000000..95fafd7 --- /dev/null +++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.xml @@ -0,0 +1,69 @@ +<?xml version="1.0"?> +<doc> + <assembly> + <name>VNLib.Plugins.Sessions.Cache.Client</name> + </assembly> + <members> + <member name="T:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException"> + <summary> + Raised when a request message is too large to send to + the server and the server may close the connection. + </summary> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException.#ctor"> + <inheritdoc/> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException.#ctor(System.String)"> + <inheritdoc/> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException.#ctor(System.String,System.Exception)"> + <inheritdoc/> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException.#ctor(System.Runtime.Serialization.SerializationInfo,System.Runtime.Serialization.StreamingContext)"> + <inheritdoc/> + </member> + <member name="T:VNLib.Plugins.Sessions.Cache.Client.SessionClient"> + <summary> + A client that allows access to sessions located on external servers + </summary> + </member> + <member name="P:VNLib.Plugins.Sessions.Cache.Client.SessionClient.GetSessionId"> + <summary> + A callback that produces a session-id from the connection (or a new id if needed) + </summary> + </member> + <member name="P:VNLib.Plugins.Sessions.Cache.Client.SessionClient.NewSessionId"> + <summary> + A callback that produces a new session-id for the connection (and updates the client if necessary) + </summary> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.#ctor(System.Int32,System.Int32,System.Int32,VNLib.Utils.Logging.ILogProvider,VNLib.Utils.Memory.PrivateHeap)"> + <summary> + Initializes a new <see cref="T:VNLib.Plugins.Sessions.Cache.Client.SessionClient"/> + </summary> + <param name="maxMessageSize">The maxium message size (in bytes) the client will allow receiving (maximum data size for sessions)</param> + <param name="recvBufferSize">The size (in bytes) of the client message receive buffer</param> + <param name="maxCacheItems">The maximum number of sessions to keep in memory</param> + <param name="log">A <see cref="T:VNLib.Utils.Logging.ILogProvider"/> to write log events to</param> + <param name="heap">The <see cref="T:VNLib.Utils.Memory.PrivateHeap"/> to allocate buffers from</param> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.GetSessionAsync(VNLib.Net.Http.HttpEvent,System.Threading.CancellationToken)"> + <inheritdoc/> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.CacheClear"> + <inheritdoc/> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.CacheHardClear"> + <inheritdoc/> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.OnConnected"> + <inheritdoc/> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.OnError(VNLib.Net.Messaging.FBM.Client.FMBClientErrorEventArgs)"> + <inheritdoc/> + </member> + <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.OnDisconnected"> + <inheritdoc/> + </member> + </members> +</doc> |