diff options
author | vnugent <public@vaughnnugent.com> | 2023-03-09 01:48:28 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2023-03-09 01:48:28 -0500 |
commit | 5ddef0fcb742e77b99a0e17015d2eea0a1d4131a (patch) | |
tree | c1c88284b11b70d9f373215d8d54e8a168cc5700 | |
parent | dab71d5597fdfbe71f6ac310a240835716e952a5 (diff) |
Omega cache, session, and account provider complete overhaul
48 files changed, 2513 insertions, 1605 deletions
diff --git a/lib/Hashing.Portable/src/Argon2/VnArgon2.cs b/lib/Hashing.Portable/src/Argon2/VnArgon2.cs index 5963ca7..519d35f 100644 --- a/lib/Hashing.Portable/src/Argon2/VnArgon2.cs +++ b/lib/Hashing.Portable/src/Argon2/VnArgon2.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Hashing.Portable @@ -25,6 +25,7 @@ using System; using System.Text; +using System.Buffers; using System.Threading; using System.Buffers.Text; using System.Security.Cryptography; @@ -142,7 +143,7 @@ namespace VNLib.Hashing int passBytes = LocEncoding.GetByteCount(password); //Alloc memory for salt - using MemoryHandle<byte> buffer = PwHeap.Alloc<byte>(saltbytes + passBytes, true); + using IMemoryHandle<byte> buffer = PwHeap.Alloc<byte>(saltbytes + passBytes, true); Span<byte> saltBuffer = buffer.AsSpan(0, saltbytes); Span<byte> passBuffer = buffer.AsSpan(passBytes); @@ -178,10 +179,10 @@ namespace VNLib.Hashing int passBytes = LocEncoding.GetByteCount(password); //Alloc memory for password - using MemoryHandle<byte> pwdHandle = PwHeap.Alloc<byte>(passBytes, true); + using IMemoryHandle<byte> pwdHandle = PwHeap.Alloc<byte>(passBytes, true); //Encode password, create a new span to make sure its proper size - _ = LocEncoding.GetBytes(password, pwdHandle); + _ = LocEncoding.GetBytes(password, pwdHandle.Span); //Hash return Hash2id(pwdHandle.Span, salt, secret, timeCost, memCost, parallelism, hashLen); @@ -205,7 +206,7 @@ namespace VNLib.Hashing { string hash, salts; //Alloc data for hash output - using MemoryHandle<byte> hashHandle = PwHeap.Alloc<byte>(hashLen, true); + using IMemoryHandle<byte> hashHandle = PwHeap.Alloc<byte>(hashLen, true); //hash the password Hash2id(password, salt, secret, hashHandle.Span, timeCost, memCost, parallelism); @@ -219,6 +220,7 @@ namespace VNLib.Hashing //Encode salt in base64 return $"${ID_MODE}$v={(int)Argon2_version.VERSION_13},m={memCost},t={timeCost},p={parallelism},s={salts}${hash}"; } + /// <summary> /// Exposes the raw Argon2-ID hashing api to C#, using spans (pins memory references) @@ -231,7 +233,7 @@ namespace VNLib.Hashing /// <param name="parallelism">Degree of parallelism</param> /// <param name="timeCost">Time cost of operation</param> /// <exception cref="VnArgon2Exception"></exception> - public static void Hash2id(ReadOnlySpan<byte> password, ReadOnlySpan<byte> salt, ReadOnlySpan<byte> secret, in Span<byte> rawHashOutput, + public static void Hash2id(ReadOnlySpan<byte> password, ReadOnlySpan<byte> salt, ReadOnlySpan<byte> secret, Span<byte> rawHashOutput, uint timeCost = 2, uint memCost = 65535, uint parallelism = 4) { fixed (byte* pwd = password, slptr = salt, secretptr = secret, outPtr = rawHashOutput) @@ -289,7 +291,10 @@ namespace VNLib.Hashing uint timeCost = 2, uint memCost = 65535, uint parallelism = 4) { //Alloc data for hash output - using MemoryHandle<byte> outputHandle = PwHeap.Alloc<byte>(hashBytes.Length, true); + using IMemoryHandle<byte> outputHandle = PwHeap.Alloc<byte>(hashBytes.Length, true); + + //Pin to get the base pointer + using MemoryHandle outputPtr = outputHandle.Pin(0); //Get pointers fixed (byte* secretptr = secret, pwd = rawPass, slptr = salt) @@ -318,7 +323,7 @@ namespace VNLib.Hashing context->secret = secretptr; context->secretlen = (uint)secret.Length; //Output - context->outptr = outputHandle.Base; + context->outptr = outputPtr.Pointer; context->outlen = (uint)outputHandle.Length; //Hash Argon2_ErrorCodes result = (Argon2_ErrorCodes)_nativeLibrary.Value.Argon2Hash(&ctx); @@ -365,7 +370,7 @@ namespace VNLib.Hashing int rawPassLen = LocEncoding.GetByteCount(rawPass); //Alloc buffer for decoded data - using MemoryHandle<byte> rawBufferHandle = MemoryUtil.Shared.Alloc<byte>(passBase64BufSize + saltBase64BufSize + rawPassLen, true); + using IMemoryHandle<byte> rawBufferHandle = MemoryUtil.Shared.Alloc<byte>(passBase64BufSize + saltBase64BufSize + rawPassLen, true); //Split buffers Span<byte> saltBuf = rawBufferHandle.Span[..saltBase64BufSize]; diff --git a/lib/Hashing.Portable/src/IdentityUtility/IJsonWebKey.cs b/lib/Hashing.Portable/src/IdentityUtility/IJsonWebKey.cs new file mode 100644 index 0000000..8e0f253 --- /dev/null +++ b/lib/Hashing.Portable/src/IdentityUtility/IJsonWebKey.cs @@ -0,0 +1,50 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Hashing.Portable +* File: IJsonWebKey.cs +* +* IJsonWebKey.cs is part of VNLib.Hashing.Portable which is part +* of the larger VNLib collection of libraries and utilities. +* +* VNLib.Hashing.Portable is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Hashing.Portable is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Hashing.Portable. If not, see http://www.gnu.org/licenses/. +*/ + +namespace VNLib.Hashing.IdentityUtility +{ + /// <summary> + /// An abstraction for basic JsonWebKey operations + /// </summary> + public interface IJsonWebKey + { + /// <summary> + /// The key usage, may be Siganture, or Encryption + /// </summary> + JwkKeyUsage KeyUse { get; } + + /// <summary> + /// The cryptographic algorithm this key is to be used for + /// </summary> + string Algorithm { get; } + + /// <summary> + /// Gets miscelaneous key properties on demand. May return null results if the property + /// is not defined in the current key + /// </summary> + /// <param name="propertyName">The name of the key property to get</param> + /// <returns>The value at the key property</returns> + string? GetKeyProperty(string propertyName); + } +} diff --git a/lib/Hashing.Portable/src/IdentityUtility/JsonWebKey.cs b/lib/Hashing.Portable/src/IdentityUtility/JsonWebKey.cs index 9076e5b..e8bd13f 100644 --- a/lib/Hashing.Portable/src/IdentityUtility/JsonWebKey.cs +++ b/lib/Hashing.Portable/src/IdentityUtility/JsonWebKey.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Hashing.Portable @@ -57,9 +57,32 @@ namespace VNLib.Hashing.IdentityUtility {} } + /// <summary> + /// The JWK key usage flags + /// </summary> + public enum JwkKeyUsage + { + /// <summary> + /// Default/not supported operation + /// </summary> + None, + /// <summary> + /// The key supports cryptographic signatures + /// </summary> + Signature, + /// <summary> + /// The key supports encryption operations + /// </summary> + Encryption + } + + /// <summary> + /// Contains extension methods for verifying and signing <see cref="JsonWebToken"/> + /// using <see cref="IJsonWebKey"/>s. + /// </summary> public static class JsonWebKey - { - + { + /// <summary> /// Verifies the <see cref="JsonWebToken"/> against the supplied /// Json Web Key in <see cref="JsonDocument"/> format @@ -70,81 +93,73 @@ namespace VNLib.Hashing.IdentityUtility /// <exception cref="FormatException"></exception> /// <exception cref="OutOfMemoryException"></exception> /// <exception cref="EncryptionTypeNotSupportedException"></exception> - public static bool VerifyFromJwk(this JsonWebToken token, in JsonElement jwk) - { - //Get key use and algorithm - string? use = jwk.GetPropString("use"); - string? alg = jwk.GetPropString("alg"); - + public static bool VerifyFromJwk<TKey>(this JsonWebToken token, in TKey jwk) where TKey: notnull, IJsonWebKey + { //Use and alg are required here - if(use == null || alg == null) - { - return false; - } - - //Make sure the key is used for signing/verification - if (!"sig".Equals(use, StringComparison.OrdinalIgnoreCase)) + if(jwk.KeyUse != JwkKeyUsage.Signature || jwk.Algorithm == null) { return false; } + //Get the jwt header to confirm its the same algorithm as the jwk using (JsonDocument jwtHeader = token.GetHeader()) { string? jwtAlg = jwtHeader.RootElement.GetPropString("alg"); + //Make sure the jwt was signed with the same algorithm type - if (!alg.Equals(jwtAlg, StringComparison.OrdinalIgnoreCase)) + if (!jwk.Algorithm.Equals(jwtAlg, StringComparison.OrdinalIgnoreCase)) { return false; } } - switch (alg.ToUpper(null)) + switch (jwk.Algorithm.ToUpper(null)) { //Rsa witj pkcs and pss case JWKAlgorithms.RS256: { - using RSA? rsa = GetRSAPublicKey(in jwk); + using RSA? rsa = GetRSAPublicKey(jwk); return rsa != null && token.Verify(rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } case JWKAlgorithms.RS384: { - using RSA? rsa = GetRSAPublicKey(in jwk); + using RSA? rsa = GetRSAPublicKey(jwk); return rsa != null && token.Verify(rsa, HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1); } case JWKAlgorithms.RS512: { - using RSA? rsa = GetRSAPublicKey(in jwk); + using RSA? rsa = GetRSAPublicKey(jwk); return rsa != null && token.Verify(rsa, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1); } case JWKAlgorithms.PS256: { - using RSA? rsa = GetRSAPublicKey(in jwk); + using RSA? rsa = GetRSAPublicKey(jwk); return rsa != null && token.Verify(rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pss); } case JWKAlgorithms.PS384: { - using RSA? rsa = GetRSAPublicKey(in jwk); + using RSA? rsa = GetRSAPublicKey(jwk); return rsa != null && token.Verify(rsa, HashAlgorithmName.SHA384, RSASignaturePadding.Pss); } case JWKAlgorithms.PS512: { - using RSA? rsa = GetRSAPublicKey(in jwk); + using RSA? rsa = GetRSAPublicKey(jwk); return rsa != null && token.Verify(rsa, HashAlgorithmName.SHA512, RSASignaturePadding.Pss); } //Eccurves case JWKAlgorithms.ES256: { - using ECDsa? eCDsa = GetECDsaPublicKey(in jwk); + using ECDsa? eCDsa = GetECDsaPublicKey(jwk); return eCDsa != null && token.Verify(eCDsa, HashAlgorithmName.SHA256); } case JWKAlgorithms.ES384: { - using ECDsa? eCDsa = GetECDsaPublicKey(in jwk); + using ECDsa? eCDsa = GetECDsaPublicKey(jwk); return eCDsa != null && token.Verify(eCDsa, HashAlgorithmName.SHA384); } case JWKAlgorithms.ES512: { - using ECDsa? eCDsa = GetECDsaPublicKey(in jwk); + using ECDsa? eCDsa = GetECDsaPublicKey(jwk); return eCDsa != null && token.Verify(eCDsa, HashAlgorithmName.SHA512); } default: @@ -152,18 +167,6 @@ namespace VNLib.Hashing.IdentityUtility } } - - /// <summary> - /// Verifies the <see cref="JsonWebToken"/> against the supplied - /// <see cref="ReadOnlyJsonWebKey"/> - /// </summary> - /// <param name="token"></param> - /// <param name="jwk">The supplied single Json Web Key</param> - /// <returns>True if required JWK data exists, ciphers were created, and data is verified, false otherwise</returns> - /// <exception cref="FormatException"></exception> - /// <exception cref="OutOfMemoryException"></exception> - /// <exception cref="EncryptionTypeNotSupportedException"></exception> - public static bool VerifyFromJwk(this JsonWebToken token, ReadOnlyJsonWebKey jwk) => token.VerifyFromJwk(jwk.KeyElement); /// <summary> /// Signs the <see cref="JsonWebToken"/> with the supplied JWK json element @@ -173,66 +176,63 @@ namespace VNLib.Hashing.IdentityUtility /// <exception cref="ArgumentNullException"></exception> /// <exception cref="InvalidOperationException"></exception> /// <exception cref="EncryptionTypeNotSupportedException"></exception> - public static void SignFromJwk(this JsonWebToken token, in JsonElement jwk) + public static void SignFromJwk<T>(this JsonWebToken token, in T jwk) where T: notnull, IJsonWebKey { _ = token ?? throw new ArgumentNullException(nameof(token)); - //Get key use and algorithm - string? use = jwk.GetPropString("use"); - string? alg = jwk.GetPropString("alg"); - //Use and alg are required here - if (use == null || alg == null) + //Make sure the key is used for signing/verification + if (jwk.KeyUse != JwkKeyUsage.Signature) { - throw new InvalidOperationException("Algorithm or JWK use is null"); + throw new InvalidOperationException("The JWK cannot be used for signing"); } - //Make sure the key is used for signing/verification - if (!"sig".Equals(use, StringComparison.OrdinalIgnoreCase)) + //Alg is a required property + if (jwk.Algorithm == null) { - throw new InvalidOperationException("The JWK cannot be used for signing"); + throw new InvalidOperationException("Algorithm or JWK use is null"); } - switch (alg.ToUpper(null)) + switch (jwk.Algorithm.ToUpper(null)) { //Rsa witj pkcs and pss case JWKAlgorithms.RS256: { - using RSA? rsa = GetRSAPrivateKey(in jwk); + using RSA? rsa = GetRSAPrivateKey(jwk); _ = rsa ?? throw new InvalidOperationException("JWK Does not contain an RSA private key"); token.Sign(rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1, 128); return; } case JWKAlgorithms.RS384: { - using RSA? rsa = GetRSAPrivateKey(in jwk); + using RSA? rsa = GetRSAPrivateKey(jwk); _ = rsa ?? throw new InvalidOperationException("JWK Does not contain an RSA private key"); token.Sign(rsa, HashAlgorithmName.SHA384, RSASignaturePadding.Pkcs1, 128); return; } case JWKAlgorithms.RS512: { - using RSA? rsa = GetRSAPrivateKey(in jwk); + using RSA? rsa = GetRSAPrivateKey(jwk); _ = rsa ?? throw new InvalidOperationException("JWK Does not contain an RSA private key"); token.Sign(rsa, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1, 256); return; } case JWKAlgorithms.PS256: { - using RSA? rsa = GetRSAPrivateKey(in jwk); + using RSA? rsa = GetRSAPrivateKey(jwk); _ = rsa ?? throw new InvalidOperationException("JWK Does not contain an RSA private key"); token.Sign(rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pss, 128); return; } case JWKAlgorithms.PS384: { - using RSA? rsa = GetRSAPrivateKey(in jwk); + using RSA? rsa = GetRSAPrivateKey(jwk); _ = rsa ?? throw new InvalidOperationException("JWK Does not contain an RSA private key"); token.Sign(rsa, HashAlgorithmName.SHA384, RSASignaturePadding.Pss, 128); return; } case JWKAlgorithms.PS512: { - using RSA? rsa = GetRSAPrivateKey(in jwk); + using RSA? rsa = GetRSAPrivateKey(jwk); _ = rsa ?? throw new InvalidOperationException("JWK Does not contain an RSA private key"); token.Sign(rsa, HashAlgorithmName.SHA512, RSASignaturePadding.Pss, 256); return; @@ -240,21 +240,21 @@ namespace VNLib.Hashing.IdentityUtility //Eccurves case JWKAlgorithms.ES256: { - using ECDsa? eCDsa = GetECDsaPrivateKey(in jwk); + using ECDsa? eCDsa = GetECDsaPrivateKey(jwk); _ = eCDsa ?? throw new InvalidOperationException("JWK Does not contain an ECDsa private key"); token.Sign(eCDsa, HashAlgorithmName.SHA256, 128); return; } case JWKAlgorithms.ES384: { - using ECDsa? eCDsa = GetECDsaPrivateKey(in jwk); + using ECDsa? eCDsa = GetECDsaPrivateKey(jwk); _ = eCDsa ?? throw new InvalidOperationException("JWK Does not contain an ECDsa private key"); token.Sign(eCDsa, HashAlgorithmName.SHA384, 128); return; } case JWKAlgorithms.ES512: { - using ECDsa? eCDsa = GetECDsaPrivateKey(in jwk); + using ECDsa? eCDsa = GetECDsaPrivateKey(jwk); _ = eCDsa ?? throw new InvalidOperationException("JWK Does not contain an ECDsa private key"); token.Sign(eCDsa, HashAlgorithmName.SHA512, 256); return; @@ -265,35 +265,11 @@ namespace VNLib.Hashing.IdentityUtility } /// <summary> - /// Signs the <see cref="JsonWebToken"/> with the supplied JWK json element - /// </summary> - /// <param name="token"></param> - /// <param name="jwk">The JWK in the <see cref="ReadOnlyJsonWebKey"/> </param> - /// <exception cref="ArgumentNullException"></exception> - /// <exception cref="InvalidOperationException"></exception> - /// <exception cref="EncryptionTypeNotSupportedException"></exception> - public static void SignFromJwk(this JsonWebToken token, ReadOnlyJsonWebKey jwk) => token.SignFromJwk(jwk.KeyElement); - - /// <summary> - /// Gets the <see cref="RSA"/> public key algorithm for the current <see cref="ReadOnlyJsonWebKey"/> - /// </summary> - /// <param name="key"></param> - /// <returns>The <see cref="RSA"/> algorithm of the public key if loaded</returns> - public static RSA? GetRSAPublicKey(this ReadOnlyJsonWebKey key) => key == null ? null : GetRSAPublicKey(key.KeyElement); - - /// <summary> - /// Gets the <see cref="RSA"/> private key algorithm for the current <see cref="ReadOnlyJsonWebKey"/> - /// </summary> - /// <param name="key"></param> - ///<returns>The <see cref="RSA"/> algorithm of the private key key if loaded</returns> - public static RSA? GetRSAPrivateKey(this ReadOnlyJsonWebKey key) => key == null ? null : GetRSAPrivateKey(key.KeyElement); - - /// <summary> /// Gets the RSA public key algorithm from the supplied Json Web Key <see cref="JsonElement"/> /// </summary> /// <param name="jwk">The element that contains the JWK data</param> /// <returns>The <see cref="RSA"/> algorithm if found, or null if the element does not contain public key</returns> - public static RSA? GetRSAPublicKey(in JsonElement jwk) + public static RSA? GetRSAPublicKey<TKey>(this TKey jwk) where TKey: IJsonWebKey { RSAParameters? rSAParameters = GetRsaParameters(in jwk, false); //Create rsa from params @@ -305,18 +281,18 @@ namespace VNLib.Hashing.IdentityUtility /// </summary> /// <param name="jwk"></param> /// <returns>The <see cref="RSA"/> algorithm if found, or null if the element does not contain private key</returns> - public static RSA? GetRSAPrivateKey(in JsonElement jwk) + public static RSA? GetRSAPrivateKey<TKey>(this TKey jwk) where TKey: IJsonWebKey { RSAParameters? rSAParameters = GetRsaParameters(in jwk, true); //Create rsa from params return rSAParameters.HasValue ? RSA.Create(rSAParameters.Value) : null; } - private static RSAParameters? GetRsaParameters(in JsonElement jwk, bool includePrivateKey) + private static RSAParameters? GetRsaParameters<TKey>(in TKey jwk, bool includePrivateKey) where TKey : IJsonWebKey { //Get the RSA public key credentials - ReadOnlySpan<char> e = jwk.GetPropString("e"); - ReadOnlySpan<char> n = jwk.GetPropString("n"); + ReadOnlySpan<char> e = jwk.GetKeyProperty("e"); + ReadOnlySpan<char> n = jwk.GetKeyProperty("n"); if (e.IsEmpty || n.IsEmpty) { @@ -326,11 +302,11 @@ namespace VNLib.Hashing.IdentityUtility if (includePrivateKey) { //Get optional private key params - ReadOnlySpan<char> d = jwk.GetPropString("d"); - ReadOnlySpan<char> dp = jwk.GetPropString("dq"); - ReadOnlySpan<char> dq = jwk.GetPropString("dp"); - ReadOnlySpan<char> p = jwk.GetPropString("p"); - ReadOnlySpan<char> q = jwk.GetPropString("q"); + ReadOnlySpan<char> d = jwk.GetKeyProperty("d"); + ReadOnlySpan<char> dp = jwk.GetKeyProperty("dq"); + ReadOnlySpan<char> dq = jwk.GetKeyProperty("dp"); + ReadOnlySpan<char> p = jwk.GetKeyProperty("p"); + ReadOnlySpan<char> q = jwk.GetKeyProperty("q"); //Create params from exponent, moduls and private key components return new() @@ -353,30 +329,15 @@ namespace VNLib.Hashing.IdentityUtility Modulus = FromBase64UrlChars(n), }; } - } - - - /// <summary> - /// Gets the ECDsa public key algorithm for the current <see cref="ReadOnlyJsonWebKey"/> - /// </summary> - /// <param name="key"></param> - /// <returns>The <see cref="ECDsa"/> algorithm of the public key if loaded</returns> - public static ECDsa? GetECDsaPublicKey(this ReadOnlyJsonWebKey key) => key == null ? null : GetECDsaPublicKey(key.KeyElement); - - /// <summary> - /// Gets the <see cref="ECDsa"/> private key algorithm for the current <see cref="ReadOnlyJsonWebKey"/> - /// </summary> - /// <param name="key"></param> - ///<returns>The <see cref="ECDsa"/> algorithm of the private key key if loaded</returns> - public static ECDsa? GetECDsaPrivateKey(this ReadOnlyJsonWebKey key) => key == null ? null : GetECDsaPrivateKey(key.KeyElement); + /// <summary> /// Gets the ECDsa public key algorithm from the supplied Json Web Key <see cref="JsonElement"/> /// </summary> /// <param name="jwk">The public key element</param> /// <returns>The <see cref="ECDsa"/> algorithm from the key if loaded, null if no key data was found</returns> - public static ECDsa? GetECDsaPublicKey(in JsonElement jwk) + public static ECDsa? GetECDsaPublicKey<TKey>(this TKey jwk) where TKey: IJsonWebKey { //Get the EC params ECParameters? ecParams = GetECParameters(in jwk, false); @@ -389,7 +350,7 @@ namespace VNLib.Hashing.IdentityUtility /// </summary> /// <param name="jwk">The element that contains the private key data</param> /// <returns>The <see cref="ECDsa"/> algorithm from the key if loaded, null if no key data was found</returns> - public static ECDsa? GetECDsaPrivateKey(in JsonElement jwk) + public static ECDsa? GetECDsaPrivateKey<TKey>(this TKey jwk) where TKey : IJsonWebKey { //Get the EC params ECParameters? ecParams = GetECParameters(in jwk, true); @@ -398,14 +359,14 @@ namespace VNLib.Hashing.IdentityUtility } - private static ECParameters? GetECParameters(in JsonElement jwk, bool includePrivate) + private static ECParameters? GetECParameters<TKey>(in TKey jwk, bool includePrivate) where TKey : IJsonWebKey { //Get the RSA public key credentials - ReadOnlySpan<char> x = jwk.GetPropString("x"); - ReadOnlySpan<char> y = jwk.GetPropString("y"); + ReadOnlySpan<char> x = jwk.GetKeyProperty("x"); + ReadOnlySpan<char> y = jwk.GetKeyProperty("y"); //Optional private key - ReadOnlySpan<char> d = includePrivate ? jwk.GetPropString("d") : null; + ReadOnlySpan<char> d = includePrivate ? jwk.GetKeyProperty("d") : null; if (x.IsEmpty || y.IsEmpty) { @@ -414,7 +375,7 @@ namespace VNLib.Hashing.IdentityUtility ECCurve curve; //Get the EC curve name from the curve ID - switch (jwk.GetPropString("crv")?.ToUpper(null)) + switch (jwk.GetKeyProperty("crv")?.ToUpper(null)) { case "P-256": curve = ECCurve.NamedCurves.nistP256; diff --git a/lib/Hashing.Portable/src/IdentityUtility/ReadOnlyJsonWebKey.cs b/lib/Hashing.Portable/src/IdentityUtility/ReadOnlyJsonWebKey.cs index f86855a..a50836f 100644 --- a/lib/Hashing.Portable/src/IdentityUtility/ReadOnlyJsonWebKey.cs +++ b/lib/Hashing.Portable/src/IdentityUtility/ReadOnlyJsonWebKey.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Hashing.Portable @@ -24,10 +24,10 @@ using System; using System.Text.Json; +using System.Collections.Generic; using VNLib.Utils; using VNLib.Utils.Extensions; -using System.Collections.Generic; namespace VNLib.Hashing.IdentityUtility { @@ -35,10 +35,10 @@ namespace VNLib.Hashing.IdentityUtility /// A readonly Json Web Key (JWK) data structure that may be used for signing /// or verifying messages. /// </summary> - public sealed class ReadOnlyJsonWebKey : VnDisposeable + public sealed class ReadOnlyJsonWebKey : VnDisposeable, IJsonWebKey { private readonly JsonElement _jwk; - private readonly JsonDocument? doc; + private readonly JsonDocument? _doc; /// <summary> /// Creates a new instance of <see cref="ReadOnlyJsonWebKey"/> from a <see cref="JsonElement"/>. @@ -60,6 +60,14 @@ namespace VNLib.Hashing.IdentityUtility { "alg" , Algorithm }, { "typ" , "JWT" }, }; + + //Configure key usage + KeyUse = (Use?.ToLower(null)) switch + { + "sig" => JwkKeyUsage.Signature, + "enc" => JwkKeyUsage.Encryption, + _ => JwkKeyUsage.None, + }; } /// <summary> @@ -73,9 +81,9 @@ namespace VNLib.Hashing.IdentityUtility { //Pare the raw value Utf8JsonReader reader = new (rawValue); - doc = JsonDocument.ParseValue(ref reader); + _doc = JsonDocument.ParseValue(ref reader); //store element - _jwk = doc.RootElement; + _jwk = _doc.RootElement; //Set initial values KeyId = _jwk.GetPropString("kid"); @@ -89,6 +97,14 @@ namespace VNLib.Hashing.IdentityUtility { "alg" , Algorithm }, { "typ" , "JWT" }, }; + + //Configure key usage + KeyUse = (Use?.ToLower(null)) switch + { + "sig" => JwkKeyUsage.Signature, + "enc" => JwkKeyUsage.Encryption, + _ => JwkKeyUsage.None, + }; } /// <summary> @@ -112,16 +128,17 @@ namespace VNLib.Hashing.IdentityUtility /// Returns the JWT header that matches this key /// </summary> public IReadOnlyDictionary<string, string?> JwtHeader { get; } + + ///<inheritdoc/> + public JwkKeyUsage KeyUse { get; } - /// <summary> - /// The key element - /// </summary> - internal JsonElement KeyElement => _jwk; + ///<inheritdoc/> + public string? GetKeyProperty(string propertyName) => _jwk.GetPropString(propertyName); ///<inheritdoc/> protected override void Free() { - doc?.Dispose(); + _doc?.Dispose(); } } diff --git a/lib/Net.Http/src/Core/ConnectionInfo.cs b/lib/Net.Http/src/Core/ConnectionInfo.cs index 6e1660d..d193467 100644 --- a/lib/Net.Http/src/Core/ConnectionInfo.cs +++ b/lib/Net.Http/src/Core/ConnectionInfo.cs @@ -113,6 +113,7 @@ namespace VNLib.Net.Http }).Any(); return accepted; } + /// <summary> /// Determines if the connection accepts any content type /// </summary> @@ -122,6 +123,7 @@ namespace VNLib.Net.Http //Accept any if no accept header was present, or accept all value */* return Context.Request.Accept.Count == 0 || Accept.Where(static t => t.StartsWith("*/*", StringComparison.OrdinalIgnoreCase)).Any(); } + ///<inheritdoc/> public void SetCookie(string name, string value, string? domain, string? path, TimeSpan Expires, CookieSameSite sameSite, bool httpOnly, bool secure) { diff --git a/lib/Plugins.Essentials.ServiceStack/src/HttpServiceStack.cs b/lib/Plugins.Essentials.ServiceStack/src/HttpServiceStack.cs index 5800955..45282b3 100644 --- a/lib/Plugins.Essentials.ServiceStack/src/HttpServiceStack.cs +++ b/lib/Plugins.Essentials.ServiceStack/src/HttpServiceStack.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.ServiceStack @@ -22,15 +22,20 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + using VNLib.Utils; using VNLib.Net.Http; namespace VNLib.Plugins.Essentials.ServiceStack { /// <summary> - /// The service domain controller that manages all - /// servers for an application based on a - /// <see cref="ServiceDomain"/> + /// An HTTP servicing stack that manages a collection of HTTP servers + /// their service domain /// </summary> public sealed class HttpServiceStack : VnDisposeable { @@ -48,7 +53,7 @@ namespace VNLib.Plugins.Essentials.ServiceStack /// <summary> /// The service domain's plugin controller /// </summary> - public IPluginController PluginController => _serviceDomain; + public IPluginManager PluginManager => _serviceDomain.PluginManager; /// <summary> /// Initializes a new <see cref="HttpServiceStack"/> that will @@ -74,14 +79,14 @@ namespace VNLib.Plugins.Essentials.ServiceStack //Init new linked cts to stop all servers if cancelled _cts = CancellationTokenSource.CreateLinkedTokenSource(parentToken); - LinkedList<Task> runners = new(); + //Start all servers + Task[] runners = _servers.Select(s => s.Start(_cts.Token)).ToArray(); - foreach(HttpServer server in _servers) - { - //Start servers and add run task to list - Task run = server.Start(_cts.Token); - runners.AddLast(run); - } + //Check for failed startups + Task? firstFault = runners.Where(static t => t.IsFaulted).FirstOrDefault(); + + //Raise first exception + firstFault?.GetAwaiter().GetResult(); //Task that waits for all to exit then cleans up WaitForAllTask = Task.WhenAll(runners) @@ -96,6 +101,8 @@ namespace VNLib.Plugins.Essentials.ServiceStack /// <returns>The task that completes when</returns> public Task StopAndWaitAsync() { + Check(); + _cts?.Cancel(); return WaitForAllTask; } @@ -103,7 +110,7 @@ namespace VNLib.Plugins.Essentials.ServiceStack private void OnAllServerExit(Task allExit) { //Unload the hosts - _serviceDomain.UnloadAll(); + _serviceDomain.TearDown(); } ///<inheritdoc/> diff --git a/lib/Plugins.Essentials.ServiceStack/src/HttpServiceStackBuilder.cs b/lib/Plugins.Essentials.ServiceStack/src/HttpServiceStackBuilder.cs index bb6e96f..0b75031 100644 --- a/lib/Plugins.Essentials.ServiceStack/src/HttpServiceStackBuilder.cs +++ b/lib/Plugins.Essentials.ServiceStack/src/HttpServiceStackBuilder.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.ServiceStack @@ -22,6 +22,10 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +using System; +using System.Linq; +using System.Collections.Generic; + using VNLib.Net.Http; namespace VNLib.Plugins.Essentials.ServiceStack diff --git a/lib/Plugins.Essentials.ServiceStack/src/IPluginController.cs b/lib/Plugins.Essentials.ServiceStack/src/IPluginController.cs index 0871fdc..fb9c340 100644 --- a/lib/Plugins.Essentials.ServiceStack/src/IPluginController.cs +++ b/lib/Plugins.Essentials.ServiceStack/src/IPluginController.cs @@ -1,12 +1,12 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.ServiceStack -* File: IPluginController.cs +* File: IPluginManager.cs * -* IPluginController.cs is part of VNLib.Plugins.Essentials.ServiceStack which is part of the larger -* VNLib collection of libraries and utilities. +* IPluginManager.cs is part of VNLib.Plugins.Essentials.ServiceStack which +* is part of the larger VNLib collection of libraries and utilities. * * VNLib.Plugins.Essentials.ServiceStack is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -22,7 +22,9 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -using System.Text.Json; +using System; +using System.Threading.Tasks; +using System.Collections.Generic; using VNLib.Utils.Logging; @@ -32,16 +34,21 @@ namespace VNLib.Plugins.Essentials.ServiceStack /// Represents a live plugin controller that manages all /// plugins loaded in a <see cref="ServiceDomain"/> /// </summary> - public interface IPluginController + public interface IPluginManager { /// <summary> + /// The the plugins managed by this <see cref="IPluginManager"/> + /// </summary> + public IEnumerable<IManagedPlugin> Plugins { get; } + + /// <summary> /// Loads all plugins specified by the host config to the service manager, /// or attempts to load plugins by the default /// </summary> /// <param name="config">The configuration instance to pass to plugins</param> /// <param name="appLog">A log provider to write message and errors to</param> /// <returns>A task that resolves when all plugins are loaded</returns> - Task LoadPlugins(JsonDocument config, ILogProvider appLog); + Task LoadPluginsAsync(PluginLoadConfiguration config, ILogProvider appLog); /// <summary> /// Sends a message to a plugin identified by it's name. @@ -61,11 +68,8 @@ namespace VNLib.Plugins.Essentials.ServiceStack void ForceReloadAllPlugins(); /// <summary> - /// Unloads all service groups, removes them, and unloads all - /// loaded plugins + /// Unloads all loaded plugins and calls thier event handlers /// </summary> - /// <exception cref="AggregateException"></exception> - /// <exception cref="ObjectDisposedException"></exception> - void UnloadAll(); + void UnloadPlugins(); } } diff --git a/lib/Plugins.Essentials.ServiceStack/src/IPluginWrapper.cs b/lib/Plugins.Essentials.ServiceStack/src/IPluginWrapper.cs new file mode 100644 index 0000000..2e686ee --- /dev/null +++ b/lib/Plugins.Essentials.ServiceStack/src/IPluginWrapper.cs @@ -0,0 +1,54 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.ServiceStack +* File: IPluginWrapper.cs +* +* IPluginWrapper.cs is part of VNLib.Plugins.Essentials.ServiceStack which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.ServiceStack is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 2 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.ServiceStack is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using VNLib.Plugins.Runtime; + +namespace VNLib.Plugins.Essentials.ServiceStack +{ + + /// <summary> + /// Represents a plugin managed by a <see cref="IPluginManager"/> that includes dynamically loaded plugins + /// </summary> + public interface IManagedPlugin + { + /// <summary> + /// Exposes the internal <see cref="PluginController"/> for the loaded plugin + /// </summary> + PluginController Controller { get; } + + /// <summary> + /// The file path to the loaded plugin + /// </summary> + string PluginPath { get; } + + /// <summary> + /// The exposed services the inernal plugin provides + /// </summary> + /// <remarks> + /// WARNING: Services exposed by the plugin will abide by the plugin lifecycle, so consumers + /// must listen for plugin load/unload events to respect lifecycles properly. + /// </remarks> + IUnloadableServiceProvider Services { get; } + } +} diff --git a/lib/Plugins.Essentials.ServiceStack/src/IServiceHost.cs b/lib/Plugins.Essentials.ServiceStack/src/IServiceHost.cs index 0c8d6c1..bb4f65f 100644 --- a/lib/Plugins.Essentials.ServiceStack/src/IServiceHost.cs +++ b/lib/Plugins.Essentials.ServiceStack/src/IServiceHost.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.ServiceStack @@ -22,21 +22,43 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +using VNLib.Net.Http; + namespace VNLib.Plugins.Essentials.ServiceStack { /// <summary> - /// Represents a host that exposes a processor for host events + /// Represents an HTTP service host which provides information required + /// for HttpServer routing and the <see cref="IWebRoot"/> for proccessing + /// incomming connections /// </summary> public interface IServiceHost { /// <summary> - /// The <see cref="EventProcessor"/> to process - /// incoming HTTP connections + /// The <see cref="IWebRoot"/> that handles HTTP connection + /// processing. /// </summary> - EventProcessor Processor { get; } + IWebRoot Processor { get; } + /// <summary> /// The host's transport infomration /// </summary> IHostTransportInfo TransportInfo { get; } + + /// <summary> + /// Called when a plugin is loaded and is endpoints are extracted + /// to be placed into service. + /// </summary> + /// <param name="plugin">The loaded plugin ready to be attached</param> + /// <param name="endpoints">The dynamic endpoints of a loading plugin</param> + void OnRuntimeServiceAttach(IManagedPlugin plugin, IEndpoint[] endpoints); + + /// <summary> + /// Called when a <see cref="ServiceDomain"/>'s <see cref="IPluginManager"/> + /// unloads a given plugin, and its originally discovered endpoints + /// </summary> + /// <param name="plugin">The unloading plugin to detach</param> + /// <param name="endpoints">The endpoints of the unloading plugin to remove from service</param> + void OnRuntimeServiceDetach(IManagedPlugin plugin, IEndpoint[] endpoints); + } } diff --git a/lib/Plugins.Essentials.ServiceStack/src/IUnloadableServiceProvider.cs b/lib/Plugins.Essentials.ServiceStack/src/IUnloadableServiceProvider.cs new file mode 100644 index 0000000..fa334bd --- /dev/null +++ b/lib/Plugins.Essentials.ServiceStack/src/IUnloadableServiceProvider.cs @@ -0,0 +1,42 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.ServiceStack +* File: IUnloadableServiceProvider.cs +* +* IUnloadableServiceProvider.cs is part of VNLib.Plugins.Essentials.ServiceStack +* which is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.ServiceStack is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 2 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.ServiceStack is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.Threading; + +namespace VNLib.Plugins.Essentials.ServiceStack +{ + /// <summary> + /// A <see cref="IServiceProvider"/> that may be unloaded when the + /// assembly that is sharing the types are being disposed. + /// </summary> + public interface IUnloadableServiceProvider : IServiceProvider + { + /// <summary> + /// A token that is set cancelled state when the service provider + /// is unloaded. + /// </summary> + CancellationToken UnloadToken { get; } + } +} diff --git a/lib/Plugins.Essentials.ServiceStack/src/ManagedPlugin.cs b/lib/Plugins.Essentials.ServiceStack/src/ManagedPlugin.cs new file mode 100644 index 0000000..596ea83 --- /dev/null +++ b/lib/Plugins.Essentials.ServiceStack/src/ManagedPlugin.cs @@ -0,0 +1,202 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.ServiceStack +* File: ManagedPlugin.cs +* +* ManagedPlugin.cs is part of VNLib.Plugins.Essentials.ServiceStack which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.ServiceStack is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 2 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.ServiceStack is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Reflection; +using System.Threading.Tasks; +using System.ComponentModel.Design; + +using VNLib.Utils; +using VNLib.Plugins.Runtime; +using VNLib.Plugins.Attributes; + +namespace VNLib.Plugins.Essentials.ServiceStack +{ + + internal sealed class ManagedPlugin : VnDisposeable, IPluginEventListener, IManagedPlugin + { + private readonly IPluginEventListener _serviceDomainListener; + private readonly RuntimePluginLoader _plugin; + + private UnloadableServiceContainer? _services; + + public ManagedPlugin(string pluginPath, PluginLoadConfiguration config, IPluginEventListener listener) + { + PluginPath = pluginPath; + + //configure the loader + _plugin = new(pluginPath, config.HostConfig, config.PluginErrorLog, config.HotReload, config.HotReload); + + //Register listener before loading occurs + _plugin.Controller.Register(this, this); + + //Store listener to raise events + _serviceDomainListener = listener; + } + + ///<inheritdoc/> + public string PluginPath { get; } + + ///<inheritdoc/> + public IUnloadableServiceProvider Services + { + get + { + Check(); + return _services!; + } + } + + ///<inheritdoc/> + public PluginController Controller + { + get + { + Check(); + return _plugin.Controller; + } + } + + internal string PluginFileName => Path.GetFileName(PluginPath); + + internal Task InitializePluginsAsync() + { + Check(); + return _plugin.InitializeController(); + } + + internal void LoadPlugins() + { + Check(); + _plugin.LoadPlugins(); + } + + /* + * Automatically called after the plugin has successfully loaded + * by event handlers below + */ + private void ConfigureServices() + { + //If the service container is defined, dispose + _services?.Dispose(); + + //Init new service container + _services = new(); + + //Get types from plugin + foreach (LivePlugin plugin in _plugin.Controller.Plugins) + { + /* + * Get the exposed configurator method if declared, + * it may not be defined. + */ + ServiceConfigurator? callback = plugin.PluginType.GetMethods() + .Where(static m => m.GetCustomAttribute<ServiceConfiguratorAttribute>() != null && !m.IsAbstract) + .Select(m => m.CreateDelegate<ServiceConfigurator>(plugin.Plugin)) + .FirstOrDefault(); + + //Invoke if defined to expose services + callback?.Invoke(_services); + } + } + + internal void ReloadPlugins() + { + Check(); + _plugin.ReloadPlugins(); + } + + internal void UnloadPlugins() + { + Check(); + + //unload plugins + _plugin.UnloadAll(); + + //Services will be cleaned up by the unload event + } + + void IPluginEventListener.OnPluginLoaded(PluginController controller, object? state) + { + //Initialize services after load, before passing event + ConfigureServices(); + + //Propagate event + _serviceDomainListener.OnPluginLoaded(controller, state); + } + + void IPluginEventListener.OnPluginUnloaded(PluginController controller, object? state) + { + //Cleanup services no longer in use. Plugin is still valid until this method returns + using (_services) + { + //Propagate event + _serviceDomainListener.OnPluginUnloaded(controller, state); + + //signal service cancel before disposing + _services?.SignalUnload(); + } + //Remove ref to services + _services = null; + } + + protected override void Free() + { + //Dispose services + _services?.Dispose(); + //Unregister the listener to cleanup resources + _plugin.Controller.Unregister(this); + //Dispose loader + _plugin.Dispose(); + } + + + private sealed class UnloadableServiceContainer : ServiceContainer, IUnloadableServiceProvider + { + private readonly CancellationTokenSource _cts; + + public UnloadableServiceContainer() : base() + { + _cts = new(); + } + + ///<inheritdoc/> + CancellationToken IUnloadableServiceProvider.UnloadToken => _cts.Token; + + /// <summary> + /// Signals to listensers that the service container will be unloading + /// </summary> + internal void SignalUnload() => _cts.Cancel(); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + _cts.Dispose(); + } + } + } +} diff --git a/lib/Plugins.Essentials.ServiceStack/src/PluginLoadConfiguration.cs b/lib/Plugins.Essentials.ServiceStack/src/PluginLoadConfiguration.cs new file mode 100644 index 0000000..4974e71 --- /dev/null +++ b/lib/Plugins.Essentials.ServiceStack/src/PluginLoadConfiguration.cs @@ -0,0 +1,62 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.ServiceStack +* File: PluginLoadConfiguration.cs +* +* PluginLoadConfiguration.cs is part of VNLib.Plugins.Essentials.ServiceStack +* which is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.ServiceStack is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 2 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.ServiceStack is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +using System.Text.Json; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Runtime; + + +namespace VNLib.Plugins.Essentials.ServiceStack +{ + /// <summary> + /// Plugin loading configuration variables + /// </summary> + public readonly record struct PluginLoadConfiguration + { + /// <summary> + /// The directory containing the dynamic plugin assemblies to load + /// </summary> + public readonly string PluginDir { get; init; } + + /// <summary> + /// A value that indicates if the internal <see cref="PluginController"/> + /// allows for hot-reload/unloadable plugin assemblies. + /// </summary> + public readonly bool HotReload { get; init; } + + /// <summary> + /// The optional host configuration file to merge with plugin config + /// to pass to the loading plugin. + /// </summary> + public readonly JsonDocument? HostConfig { get; init; } + + /// <summary> + /// Passed to the underlying <see cref="RuntimePluginLoader"/> + /// holding plugins + /// </summary> + public readonly ILogProvider? PluginErrorLog { get; init; } + } +} diff --git a/lib/Plugins.Essentials.ServiceStack/src/PluginManager.cs b/lib/Plugins.Essentials.ServiceStack/src/PluginManager.cs new file mode 100644 index 0000000..cdcf7ba --- /dev/null +++ b/lib/Plugins.Essentials.ServiceStack/src/PluginManager.cs @@ -0,0 +1,240 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.ServiceStack +* File: PluginManager.cs +* +* PluginManager.cs is part of VNLib.Plugins.Essentials.ServiceStack which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.ServiceStack is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 2 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.ServiceStack is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +using System; +using System.IO; +using System.Linq; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Collections.Generic; + +using VNLib.Utils; +using VNLib.Utils.IO; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Runtime; + +namespace VNLib.Plugins.Essentials.ServiceStack +{ + + /// <summary> + /// A sealed type that manages the plugin interaction layer. Manages the lifetime of plugin + /// instances, exposes controls, and relays stateful plugin events. + /// </summary> + internal sealed class PluginManager : VnDisposeable, IPluginManager, IPluginEventListener + { + private const string PLUGIN_FILE_EXTENSION = ".dll"; + + private readonly List<ManagedPlugin> _plugins; + private readonly IReadOnlyCollection<ServiceGroup> _dependents; + + + private IEnumerable<LivePlugin> _livePlugins => _plugins.SelectMany(static p => p.Controller.Plugins); + + /// <summary> + /// The collection of internal controllers + /// </summary> + public IEnumerable<IManagedPlugin> Plugins => _plugins; + + public PluginManager(IReadOnlyCollection<ServiceGroup> dependents) + { + _plugins = new(); + _dependents = dependents; + } + + /// <inheritdoc/> + /// <exception cref="ObjectDisposedException"></exception> + public Task LoadPluginsAsync(PluginLoadConfiguration config, ILogProvider appLog) + { + Check(); + + //Load all virtual file assemblies withing the plugin folder + DirectoryInfo dir = new(config.PluginDir); + + if (!dir.Exists) + { + appLog.Warn("Plugin directory {dir} does not exist. No plugins were loaded", config.PluginDir); + return Task.CompletedTask; + } + + appLog.Information("Loading plugins. Hot-reload: {en}", config.HotReload); + + //Enumerate all dll files within this dir + IEnumerable<DirectoryInfo> dirs = dir.EnumerateDirectories("*", SearchOption.TopDirectoryOnly); + + //Select only dirs with a dll that is named after the directory name + IEnumerable<string> pluginPaths = GetPluginPaths(dirs); + + IEnumerable<string> pluginFileNames = pluginPaths.Select(static s => $"{Path.GetFileName(s)}\n"); + + appLog.Debug("Found plugin files: \n{files}", string.Concat(pluginFileNames)); + + //Initialze plugin managers + ManagedPlugin[] wrappers = pluginPaths.Select(pw => new ManagedPlugin(pw, config, this)).ToArray(); + + //Add to loaded plugins + _plugins.AddRange(wrappers); + + //Load plugins + return InitiailzeAndLoadAsync(appLog); + } + + private static IEnumerable<string> GetPluginPaths(IEnumerable<DirectoryInfo> dirs) + { + //Select only dirs with a dll that is named after the directory name + return dirs.Where(static pdir => + { + string compined = Path.Combine(pdir.FullName, pdir.Name); + string FilePath = string.Concat(compined, PLUGIN_FILE_EXTENSION); + return FileOperations.FileExists(FilePath); + }) + //Return the name of the dll file to import + .Select(static pdir => + { + string compined = Path.Combine(pdir.FullName, pdir.Name); + return string.Concat(compined, PLUGIN_FILE_EXTENSION); + }); + } + + private async Task InitiailzeAndLoadAsync(ILogProvider debugLog) + { + //Load all async + Task[] initAll = _plugins.Select(p => InitializePlugin(p, debugLog)).ToArray(); + + //Wait for initalization + await Task.WhenAll(initAll).ConfigureAwait(false); + + //Load stage, load all multithreaded + Parallel.ForEach(_plugins, p => LoadPlugin(p, debugLog)); + + debugLog.Information("Plugin loading completed"); + } + + private async Task InitializePlugin(ManagedPlugin plugin, ILogProvider debugLog) + { + try + { + //Load wrapper + await plugin.InitializePluginsAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + debugLog.Error(ex, $"Exception raised during initialzation of {plugin.PluginFileName}. It has been removed from the collection\n{ex}"); + + //Remove the plugin from the list while locking it + lock (_plugins) + { + _plugins.Remove(plugin); + } + + //Dispose the plugin + plugin.Dispose(); + } + } + + private static void LoadPlugin(ManagedPlugin plugin, ILogProvider debugLog) + { + Stopwatch sw = new(); + try + { + sw.Start(); + + //Load wrapper + plugin.LoadPlugins(); + + sw.Stop(); + + debugLog.Verbose("Loaded {pl} in {tm} ms", plugin.PluginFileName, sw.ElapsedMilliseconds); + } + catch (Exception ex) + { + debugLog.Error(ex, $"Exception raised during loading {plugin.PluginFileName}. Failed to load plugin \n{ex}"); + } + finally + { + sw.Stop(); + } + } + + /// <inheritdoc/> + public bool SendCommandToPlugin(string pluginName, string message, StringComparison nameComparison = StringComparison.Ordinal) + { + Check(); + + //Find the single plugin by its name + LivePlugin? pl = _livePlugins.Where(p => pluginName.Equals(p.PluginName, nameComparison)).SingleOrDefault(); + + //Send the command + return pl?.SendConsoleMessage(message) ?? false; + } + + /// <inheritdoc/> + public void ForceReloadAllPlugins() + { + //Reload all plugin managers + _plugins.TryForeach(static p => p.ReloadPlugins()); + } + + /// <inheritdoc/> + public void UnloadPlugins() + { + //Unload all plugin controllers + _plugins.TryForeach(static p => p.UnloadPlugins()); + + /* + * All plugin instances must be destroyed because the + * only way they will be loaded is from their files + * again, so they must be released + */ + _plugins.TryForeach(static p => p.Dispose()); + _plugins.Clear(); + } + + protected override void Free() + { + //Cleanup on dispose if unload failed + _plugins.TryForeach(static p => p.Dispose()); + _plugins.Clear(); + } + + void IPluginEventListener.OnPluginLoaded(PluginController controller, object? state) + { + //Get event listeners at event time because deps may be modified by the domain + ServiceGroup[] deps = _dependents.Select(static d => d).ToArray(); + + //run onload method + deps.TryForeach(d => d.OnPluginLoaded((IManagedPlugin)state!)); + } + + void IPluginEventListener.OnPluginUnloaded(PluginController controller, object? state) + { + //Get event listeners at event time because deps may be modified by the domain + ServiceGroup[] deps = _dependents.Select(static d => d).ToArray(); + + //Run unloaded method + deps.TryForeach(d => d.OnPluginUnloaded((IManagedPlugin)state!)); + } + } +} diff --git a/lib/Plugins.Essentials.ServiceStack/src/ServiceDomain.cs b/lib/Plugins.Essentials.ServiceStack/src/ServiceDomain.cs index 7b06e70..f0f9559 100644 --- a/lib/Plugins.Essentials.ServiceStack/src/ServiceDomain.cs +++ b/lib/Plugins.Essentials.ServiceStack/src/ServiceDomain.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.ServiceStack @@ -24,39 +24,23 @@ using System; using System.Net; -using System.Text.Json; -using System.Diagnostics; +using System.Linq; +using System.Collections.Generic; using VNLib.Utils; -using VNLib.Utils.IO; using VNLib.Utils.Extensions; -using VNLib.Utils.Logging; -using VNLib.Plugins.Runtime; -using VNLib.Plugins.Essentials.Content; -using VNLib.Plugins.Essentials.Sessions; namespace VNLib.Plugins.Essentials.ServiceStack { + /// <summary> /// Represents a domain of services and thier dynamically loaded plugins /// that will be hosted by an application service stack /// </summary> - public sealed class ServiceDomain : VnDisposeable, IPluginController + public sealed class ServiceDomain : VnDisposeable { - private const string PLUGIN_FILE_EXTENSION = ".dll"; - private const string DEFUALT_PLUGIN_DIR = "/plugins"; - private const string PLUGINS_CONFIG_ELEMENT = "plugins"; - private readonly LinkedList<ServiceGroup> _serviceGroups; - private readonly LinkedList<RuntimePluginLoader> _pluginLoaders; - - /// <summary> - /// Enumerates all loaded plugin instances - /// </summary> - public IEnumerable<IPlugin> Plugins => _pluginLoaders.SelectMany(static s => - s.LivePlugins.Where(static p => p.Plugin != null) - .Select(static s => s.Plugin!) - ); + private readonly PluginManager _plugins; /// <summary> /// Gets all service groups loaded in the service manager @@ -64,12 +48,19 @@ namespace VNLib.Plugins.Essentials.ServiceStack public IReadOnlyCollection<ServiceGroup> ServiceGroups => _serviceGroups; /// <summary> + /// Gets the internal <see cref="IPluginManager"/> that manages plugins for the entire + /// <see cref="ServiceDomain"/> + /// </summary> + public IPluginManager PluginManager => _plugins; + + /// <summary> /// Initializes a new empty <see cref="ServiceDomain"/> /// </summary> public ServiceDomain() { _serviceGroups = new(); - _pluginLoaders = new(); + //Init plugin manager and pass ref to service group collection + _plugins = new PluginManager(_serviceGroups); } /// <summary> @@ -78,8 +69,11 @@ namespace VNLib.Plugins.Essentials.ServiceStack /// </summary> /// <param name="hostBuilder">The callback method to build virtual hosts</param> /// <returns>A value that indicates if any virtual hosts were successfully loaded</returns> + /// <exception cref="ObjectDisposedException"></exception> public bool BuildDomain(Action<ICollection<IServiceHost>> hostBuilder) { + Check(); + //LL to store created hosts LinkedList<IServiceHost> hosts = new(); @@ -94,8 +88,11 @@ namespace VNLib.Plugins.Essentials.ServiceStack /// </summary> /// <param name="hosts">The enumeration of virtual hosts</param> /// <returns>A value that indicates if any virtual hosts were successfully loaded</returns> + /// <exception cref="ObjectDisposedException"></exception> public bool FromExisting(IEnumerable<IServiceHost> hosts) { + Check(); + //Get service groups and pass service group list CreateServiceGroups(_serviceGroups, hosts); return _serviceGroups.Any(); @@ -111,11 +108,12 @@ namespace VNLib.Plugins.Essentials.ServiceStack { IEnumerable<IServiceHost> groupHosts = hosts.Where(host => host.TransportInfo.TransportEndpoint.Equals(iface)); - IServiceHost[]? overlap = groupHosts.Where(vh => groupHosts.Select(static s => s.Processor.Hostname).Count(hostname => vh.Processor.Hostname == hostname) > 1).ToArray(); + //Find any duplicate hostnames for the same service gorup + IServiceHost[] overlap = groupHosts.Where(vh => groupHosts.Select(static s => s.Processor.Hostname).Count(hostname => vh.Processor.Hostname == hostname) > 1).ToArray(); - foreach (IServiceHost vh in overlap) + if(overlap.Length > 0) { - throw new ArgumentException($"The hostname '{vh.Processor.Hostname}' is already in use by another virtual host"); + throw new ArgumentException($"The hostname '{overlap.Last().Processor.Hostname}' is already in use by another virtual host"); } //init new service group around an interface and its roots @@ -125,235 +123,33 @@ namespace VNLib.Plugins.Essentials.ServiceStack } } - ///<inheritdoc/> - public Task LoadPlugins(JsonDocument config, ILogProvider appLog) - { - if (!config.RootElement.TryGetProperty(PLUGINS_CONFIG_ELEMENT, out JsonElement pluginEl)) - { - appLog.Information("Plugins element not defined in config, skipping plugin loading"); - return Task.CompletedTask; - } - - //Get the plugin directory, or set to default - string pluginDir = pluginEl.GetPropString("path") ?? Path.Combine(Directory.GetCurrentDirectory(), DEFUALT_PLUGIN_DIR); - //Get the hot reload flag - bool hotReload = pluginEl.TryGetProperty("hot_reload", out JsonElement hrel) && hrel.GetBoolean(); - - //Load all virtual file assemblies withing the plugin folder - DirectoryInfo dir = new(pluginDir); - - if (!dir.Exists) - { - appLog.Warn("Plugin directory {dir} does not exist. No plugins were loaded", pluginDir); - return Task.CompletedTask; - } - - appLog.Information("Loading plugins. Hot-reload: {en}", hotReload); - - //Enumerate all dll files within this dir - IEnumerable<DirectoryInfo> dirs = dir.EnumerateDirectories("*", SearchOption.TopDirectoryOnly); - - //Select only dirs with a dll that is named after the directory name - IEnumerable<string> pluginPaths = dirs.Where(static pdir => - { - string compined = Path.Combine(pdir.FullName, pdir.Name); - string FilePath = string.Concat(compined, PLUGIN_FILE_EXTENSION); - return FileOperations.FileExists(FilePath); - }) - //Return the name of the dll file to import - .Select(static pdir => - { - string compined = Path.Combine(pdir.FullName, pdir.Name); - return string.Concat(compined, PLUGIN_FILE_EXTENSION); - }); - - IEnumerable<string> pluginFileNames = pluginPaths.Select(static s => $"{Path.GetFileName(s)}\n"); - - appLog.Debug("Found plugin files: \n{files}", string.Concat(pluginFileNames)); - - LinkedList<Task> loading = new(); - - object listLock = new(); - - foreach (string pluginPath in pluginPaths) - { - async Task Load() - { - string pluginName = Path.GetFileName(pluginPath); - - RuntimePluginLoader plugin = new(pluginPath, config, appLog, hotReload, hotReload); - Stopwatch sw = new(); - try - { - sw.Start(); - - await plugin.InitLoaderAsync(); - - //Listen for reload events to remove and re-add endpoints - plugin.Reloaded += OnPluginReloaded; - - lock (listLock) - { - //Add to list - _pluginLoaders.AddLast(plugin); - } - - sw.Stop(); - - appLog.Verbose("Loaded {pl} in {tm} ms", pluginName, sw.ElapsedMilliseconds); - } - catch (Exception ex) - { - appLog.Error(ex, $"Exception raised during loading {pluginName}. Failed to load plugin \n{ex}"); - plugin.Dispose(); - } - finally - { - sw.Stop(); - } - } - - loading.AddLast(Load()); - } - - //Continuation to add all initial plugins to the service manager - void Continuation(Task t) - { - appLog.Verbose("Plugins loaded"); - - //Add inital endpoints for all plugins - _pluginLoaders.TryForeach(ldr => _serviceGroups.TryForeach(sg => sg.AddOrUpdateEndpointsForPlugin(ldr))); - - //Init session provider - InitSessionProvider(); - - //Init page router - InitPageRouter(); - } - - //wait for loading to completed - return Task.WhenAll(loading.ToArray()).ContinueWith(Continuation, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - } - - ///<inheritdoc/> - public bool SendCommandToPlugin(string pluginName, string message, StringComparison nameComparison = StringComparison.Ordinal) - { - Check(); - //Find the single plugin by its name - LivePlugin? pl = _pluginLoaders.Select(p => - p.LivePlugins.Where(lp => pluginName.Equals(lp.PluginName, nameComparison)) - ) - .SelectMany(static lp => lp) - .SingleOrDefault(); - //Send the command - return pl?.SendConsoleMessage(message) ?? false; - } - - ///<inheritdoc/> - public void ForceReloadAllPlugins() - { - Check(); - _pluginLoaders.TryForeach(static pl => pl.ReloadPlugin()); - } - - ///<inheritdoc/> - public void UnloadAll() + /// <summary> + /// Tears down the service domain by unloading all plugins (calling their event handlers) + /// and destroying all <see cref="ServiceGroup"/>s. This instance may be rebuilt if this + /// method returns successfully. + /// </summary> + internal void TearDown() { Check(); - //Unload service groups before unloading plugins + /* + * Unloading plugins should trigger the OnPluginUnloading + * hook which should cause all dependencies to unload linked + * types. + */ + _plugins.UnloadPlugins(); + + //Manually cleanup if unload missed data _serviceGroups.TryForeach(static sg => sg.UnloadAll()); //empty service groups _serviceGroups.Clear(); - - //Unload all plugins - _pluginLoaders.TryForeach(static pl => pl.UnloadAll()); - } - - private void OnPluginReloaded(object? plugin, EventArgs empty) - { - //Update endpoints for the loader - RuntimePluginLoader reloaded = (plugin as RuntimePluginLoader)!; - - //Update all endpoints for the plugin - _serviceGroups.TryForeach(sg => sg.AddOrUpdateEndpointsForPlugin(reloaded)); - } - - private void InitSessionProvider() - { - //Callback to reload provider - void onSessionProviderReloaded(ISessionProvider old, ISessionProvider current) - { - _serviceGroups.TryForeach(sg => sg.UpdateSessionProvider(current)); - } - - try - { - //get the loader that contains the single session provider - RuntimePluginLoader? sessionLoader = _pluginLoaders - .Where(static s => s.ExposesType<ISessionProvider>()) - .SingleOrDefault(); - - //If session provider has been supplied, load it - if (sessionLoader != null) - { - //Get the session provider from the plugin loader - ISessionProvider sp = sessionLoader.GetExposedTypeFromPlugin<ISessionProvider>()!; - - //Init inital provider - onSessionProviderReloaded(null!, sp); - - //Register reload event - sessionLoader.RegisterListenerForSingle<ISessionProvider>(onSessionProviderReloaded); - } - } - catch (InvalidOperationException) - { - throw new TypeLoadException("More than one session provider plugin was defined in the plugin directory, cannot continue"); - } - } - - private void InitPageRouter() - { - //Callback to reload provider - void onRouterReloaded(IPageRouter old, IPageRouter current) - { - _serviceGroups.TryForeach(sg => sg.UpdatePageRouter(current)); - } - - try - { - - //get the loader that contains the single page router - RuntimePluginLoader? routerLoader = _pluginLoaders - .Where(static s => s.ExposesType<IPageRouter>()) - .SingleOrDefault(); - - //If router has been supplied, load it - if (routerLoader != null) - { - //Get initial value - IPageRouter sp = routerLoader.GetExposedTypeFromPlugin<IPageRouter>()!; - - //Init inital provider - onRouterReloaded(null!, sp); - - //Register reload event - routerLoader.RegisterListenerForSingle<IPageRouter>(onRouterReloaded); - } - } - catch (InvalidOperationException) - { - throw new TypeLoadException("More than one page router plugin was defined in the plugin directory, cannot continue"); - } } + ///<inheritdoc/> protected override void Free() { - //Dispose loaders - _pluginLoaders.TryForeach(static pl => pl.Dispose()); - _pluginLoaders.Clear(); + _plugins.Dispose(); _serviceGroups.Clear(); } } diff --git a/lib/Plugins.Essentials.ServiceStack/src/ServiceGroup.cs b/lib/Plugins.Essentials.ServiceStack/src/ServiceGroup.cs index f57a6f9..2801776 100644 --- a/lib/Plugins.Essentials.ServiceStack/src/ServiceGroup.cs +++ b/lib/Plugins.Essentials.ServiceStack/src/ServiceGroup.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.ServiceStack @@ -23,13 +23,11 @@ */ using System.Net; +using System.Linq; using System.Collections.Generic; using System.Runtime.CompilerServices; using VNLib.Utils.Extensions; -using VNLib.Plugins.Runtime; -using VNLib.Plugins.Essentials.Content; -using VNLib.Plugins.Essentials.Sessions; namespace VNLib.Plugins.Essentials.ServiceStack { @@ -42,7 +40,7 @@ namespace VNLib.Plugins.Essentials.ServiceStack public sealed class ServiceGroup { private readonly LinkedList<IServiceHost> _vHosts; - private readonly ConditionalWeakTable<RuntimePluginLoader, IEndpoint[]> _endpointsForPlugins; + private readonly ConditionalWeakTable<IManagedPlugin, IEndpoint[]> _endpointsForPlugins; /// <summary> /// The <see cref="IPEndPoint"/> transport endpoint for all loaded service hosts @@ -68,61 +66,43 @@ namespace VNLib.Plugins.Essentials.ServiceStack } /// <summary> - /// Sets the specified page rotuer for all virtual hosts + /// Manually detatches runtime services and their loaded endpoints from all + /// endpoints. /// </summary> - /// <param name="router">The page router to user</param> - internal void UpdatePageRouter(IPageRouter router) => _vHosts.TryForeach(v => v.Processor.SetPageRouter(router)); - /// <summary> - /// Sets the specified session provider for all virtual hosts - /// </summary> - /// <param name="current">The session provider to use</param> - internal void UpdateSessionProvider(ISessionProvider current) => _vHosts.TryForeach(v => v.Processor.SetSessionProvider(current)); + internal void UnloadAll() + { + //Remove all loaded endpoints + _vHosts.TryForeach(v => _endpointsForPlugins.TryForeach(eps => v.OnRuntimeServiceDetach(eps.Key, eps.Value))); - /// <summary> - /// Adds or updates all endpoints exported by all plugins - /// within the specified loader. All endpoints exposed - /// by a previously loaded instance are removed and all - /// currently exposed endpoints are added to all virtual - /// hosts - /// </summary> - /// <param name="loader">The plugin loader to get add/update endpoints from</param> - internal void AddOrUpdateEndpointsForPlugin(RuntimePluginLoader loader) + //Clear all hosts + _vHosts.Clear(); + //Clear all endpoints + _endpointsForPlugins.Clear(); + } + + internal void OnPluginLoaded(IManagedPlugin controller) { //Get all new endpoints for plugin - IEndpoint[] newEndpoints = loader.LivePlugins.SelectMany(static pl => pl.Plugin!.GetEndpoints()).ToArray(); - - //See if - if(_endpointsForPlugins.TryGetValue(loader, out IEndpoint[]? oldEps)) - { - //Remove old endpoints - _vHosts.TryForeach(v => v.Processor.RemoveEndpoint(oldEps)); - } + IEndpoint[] newEndpoints = controller.Controller.Plugins.SelectMany(static pl => pl.Plugin!.GetEndpoints()).ToArray(); //Add endpoints to dict - _endpointsForPlugins.AddOrUpdate(loader, newEndpoints); + _endpointsForPlugins.AddOrUpdate(controller, newEndpoints); //Add endpoints to hosts - _vHosts.TryForeach(v => v.Processor.AddEndpoint(newEndpoints)); + _vHosts.TryForeach(v => v.OnRuntimeServiceAttach(controller, newEndpoints)); } - /// <summary> - /// Unloads all previously stored endpoints, router, session provider, and - /// clears all internal data structures - /// </summary> - internal void UnloadAll() + internal void OnPluginUnloaded(IManagedPlugin controller) { - //Remove all loaded endpoints - _vHosts.TryForeach(v => _endpointsForPlugins.TryForeach(eps => v.Processor.RemoveEndpoint(eps.Value))); - - //Remove all routers - _vHosts.TryForeach(static v => v.Processor.SetPageRouter(null)); - //Remove all session providers - _vHosts.TryForeach(static v => v.Processor.SetSessionProvider(null)); + //Get the old endpoints from the controller referrence and remove them + if (_endpointsForPlugins.TryGetValue(controller, out IEndpoint[]? oldEps)) + { + //Remove the old endpoints + _vHosts.TryForeach(v => v.OnRuntimeServiceDetach(controller, oldEps)); - //Clear all hosts - _vHosts.Clear(); - //Clear all endpoints - _endpointsForPlugins.Clear(); + //remove controller ref + _ = _endpointsForPlugins.Remove(controller); + } } } } diff --git a/lib/Plugins.Essentials.ServiceStack/src/VNLib.Plugins.Essentials.ServiceStack.csproj b/lib/Plugins.Essentials.ServiceStack/src/VNLib.Plugins.Essentials.ServiceStack.csproj index 4918c49..dd7b562 100644 --- a/lib/Plugins.Essentials.ServiceStack/src/VNLib.Plugins.Essentials.ServiceStack.csproj +++ b/lib/Plugins.Essentials.ServiceStack/src/VNLib.Plugins.Essentials.ServiceStack.csproj @@ -2,7 +2,6 @@ <PropertyGroup> <TargetFramework>net6.0</TargetFramework> - <ImplicitUsings>enable</ImplicitUsings> <RootNamespace>VNLib.Plugins.Essentials.ServiceStack</RootNamespace> <AssemblyName>VNLib.Plugins.Essentials.ServiceStack</AssemblyName> <Nullable>enable</Nullable> diff --git a/lib/Plugins.Essentials/src/Accounts/AccountUtils.cs b/lib/Plugins.Essentials/src/Accounts/AccountUtils.cs index 610d646..75c3388 100644 --- a/lib/Plugins.Essentials/src/Accounts/AccountUtils.cs +++ b/lib/Plugins.Essentials/src/Accounts/AccountUtils.cs @@ -1,11 +1,11 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials -* File: AccountManager.cs +* File: AccountUtil.cs * -* AccountManager.cs is part of VNLib.Plugins.Essentials which is part of the larger +* AccountUtil.cs is part of VNLib.Plugins.Essentials which is part of the larger * VNLib collection of libraries and utilities. * * VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify @@ -23,21 +23,19 @@ */ using System; -using System.IO; -using System.Text; +using System.Buffers; +using System.Threading; using System.Threading.Tasks; using System.Security.Cryptography; using System.Text.RegularExpressions; using System.Runtime.CompilerServices; using VNLib.Hashing; -using VNLib.Net.Http; using VNLib.Utils; using VNLib.Utils.Memory; using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Sessions; -using VNLib.Plugins.Essentials.Extensions; #nullable enable @@ -51,60 +49,24 @@ namespace VNLib.Plugins.Essentials.Accounts /// </summary> public static partial class AccountUtil { - public const int MAX_EMAIL_CHARS = 50; - public const int ID_FIELD_CHARS = 65; - public const int STREET_ADDR_CHARS = 150; - public const int MAX_LOGIN_COUNT = 10; - public const int MAX_FAILED_RESET_ATTEMPS = 5; /// <summary> - /// The maximum time in seconds for a login message to be considered valid - /// </summary> - public const double MAX_TIME_DIFF_SECS = 10.00; - /// <summary> /// The size in bytes of the random passwords generated when invoking the <see cref="SetRandomPasswordAsync(PasswordHashing, IUserManager, IUser, int)"/> /// </summary> public const int RANDOM_PASS_SIZE = 128; - /// <summary> - /// The name of the header that will identify a client's identiy - /// </summary> - public const string LOGIN_TOKEN_HEADER = "X-Web-Token"; + /// <summary> /// The origin string of a local user account. This value will be set if an /// account is created through the VNLib.Plugins.Essentials.Accounts library /// </summary> public const string LOCAL_ACCOUNT_ORIGIN = "local"; - /// <summary> - /// The size (in bytes) of the challenge secret - /// </summary> - public const int CHALLENGE_SIZE = 64; - /// <summary> - /// The size (in bytes) of the sesssion long user-password challenge - /// </summary> - public const int SESSION_CHALLENGE_SIZE = 128; - - //The buffer size to use when decoding the base64 public key from the user - private const int PUBLIC_KEY_BUFFER_SIZE = 1024; - /// <summary> - /// The name of the login cookie set when a user logs in - /// </summary> - public const string LOGIN_COOKIE_NAME = "VNLogin"; - /// <summary> - /// The name of the login client identifier cookie (cookie that is set fir client to use to determine if the user is logged in) - /// </summary> - public const string LOGIN_COOKIE_IDENTIFIER = "li"; - - private const int LOGIN_COOKIE_SIZE = 64; - + + //Session entry keys private const string BROWSER_ID_ENTRY = "acnt.bid"; - private const string CLIENT_PUB_KEY_ENTRY = "acnt.pbk"; - private const string CHALLENGE_HMAC_ENTRY = "acnt.cdig"; private const string FAILED_LOGIN_ENTRY = "acnt.flc"; private const string LOCAL_ACCOUNT_ENTRY = "acnt.ila"; private const string ACC_ORIGIN_ENTRY = "__.org"; - private const string TOKEN_UPDATE_TIME_ENTRY = "acnt.tut"; - //private const string CHALLENGE_HASH_ENTRY = "acnt.chl"; //Privlage masks public const ulong READ_MSK = 0x0000000000000001L; @@ -122,19 +84,6 @@ namespace VNLib.Plugins.Essentials.Accounts public const ulong MINIMUM_LEVEL = 0x0000000100000001L; - //Timeouts - public static readonly TimeSpan LoginCookieLifespan = TimeSpan.FromHours(1); - public static readonly TimeSpan RegenIdPeriod = TimeSpan.FromMinutes(25); - - /// <summary> - /// The client data encryption padding. - /// </summary> - public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; - - /// <summary> - /// The size (in bytes) of the web-token hash size - /// </summary> - private static readonly int TokenHashSize = (SHA384.Create().HashSize / 8); /// <summary> /// Speical character regual expresion for basic checks @@ -154,7 +103,7 @@ namespace VNLib.Plugins.Essentials.Accounts /// <exception cref="VnArgon2Exception"></exception> /// <exception cref="ArgumentException"></exception> /// <exception cref="ArgumentNullException"></exception> - public static async Task<ERRNO> SetRandomPasswordAsync(this PasswordHashing passHashing, IUserManager manager, IUser user, int size = RANDOM_PASS_SIZE) + public static async Task<ERRNO> SetRandomPasswordAsync(this IPasswordHashingProvider passHashing, IUserManager manager, IUser user, int size = RANDOM_PASS_SIZE) { _ = manager ?? throw new ArgumentNullException(nameof(manager)); _ = user ?? throw new ArgumentNullException(nameof(user)); @@ -208,512 +157,292 @@ namespace VNLib.Plugins.Essentials.Accounts [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string GetRandomUserId() => RandomHash.GetRandomHash(HashAlg.SHA1, 64, HashEncodingMode.Hexadecimal); - #endregion - - #region Client Auth Extensions - /// <summary> - /// Runs necessary operations to grant authorization to the specified user of a given session and user with provided variables + /// Generates a cryptographically secure random password, then hashes it + /// and returns the hash of the new password /// </summary> - /// <param name="ev">The connection and session to log-in</param> - /// <param name="loginMessage">The message of the client to set the log-in status of</param> - /// <param name="user">The user to log-in</param> - /// <returns>The encrypted base64 token secret data to send to the client</returns> - /// <exception cref="OutOfMemoryException"></exception> - /// <exception cref="CryptographicException"></exception> - public static string GenerateAuthorization(this HttpEntity ev, LoginMessage loginMessage, IUser user) + /// <param name="hashing"></param> + /// <param name="size">The size (in bytes) of the new random password</param> + /// <returns>A <see cref="PrivateString"/> that contains the new password hash</returns> + public static PrivateString GetRandomPassword(this IPasswordHashingProvider hashing, int size = RANDOM_PASS_SIZE) { - return GenerateAuthorization(ev, loginMessage.ClientPublicKey, loginMessage.ClientID, user); - } + //Get random bytes + byte[] randBuffer = ArrayPool<byte>.Shared.Rent(size); + try + { + Span<byte> span = randBuffer.AsSpan(0, size); - /// <summary> - /// Runs necessary operations to grant authorization to the specified user of a given session and user with provided variables - /// </summary> - /// <param name="ev">The connection and session to log-in</param> - /// <param name="base64PubKey">The clients base64 public key</param> - /// <param name="clientId">The browser/client id</param> - /// <param name="user">The user to log-in</param> - /// <returns>The encrypted base64 token secret data to send to the client</returns> - /// <exception cref="OutOfMemoryException"></exception> - /// <exception cref="CryptographicException"></exception> - /// <exception cref="InvalidOperationException"></exception> - public static string GenerateAuthorization(this HttpEntity ev, string base64PubKey, string clientId, IUser user) - { - if (!ev.Session.IsSet || ev.Session.SessionType != SessionType.Web) + //Generate random password + RandomHash.GetRandomBytes(span); + + //hash the password + return hashing.Hash(span); + } + finally { - throw new InvalidOperationException("The session is not set or the session is not a web-based session type"); + //Zero the block and return to pool + MemoryUtil.InitializeBlock(randBuffer.AsSpan()); + ArrayPool<byte>.Shared.Return(randBuffer); } - //Update session-id for "upgrade" - ev.Session.RegenID(); - //derrive token from login data - TryGenerateToken(base64PubKey, out string base64ServerToken, out string base64ClientData); - //Clear flags - user.FailedLoginCount(0); - //Get the "local" account flag from the user object - bool localAccount = user.IsLocalAccount(); - //Set login cookie and session login hash - ev.SetLogin(localAccount); - //Store variables - ev.Session.UserID = user.UserID; - ev.Session.Privilages = user.Privilages; - //Store browserid/client id if specified - SetBrowserID(in ev.Session, clientId); - //Store the clients public key - SetBrowserPubKey(in ev.Session, base64PubKey); - //Set local account flag - ev.Session.HasLocalAccount(localAccount); - //Store the base64 server key to compute the hmac later - ev.Session.Token = base64ServerToken; - //Update the last token upgrade time - ev.Session.LastTokenUpgrade(ev.RequestedTimeUtc); - //Return the client encrypted data - return base64ClientData; } - /* - * Notes for RSA client token generator code below - * - * To log-in a client with the following API the calling code - * must have already determined that the client should be - * logged in (verified passwords or auth tokens). - * - * The client will send a LoginMessage object that will - * contain the following Information. - * - * - The clients RSA public key in base64 subject-key info format - * - The client browser's id hex string - * - The clients local-time - * - * The TryGenerateToken method, will generate a random-byte token, - * encrypt it using the clients RSA public key, return the encrypted - * token data to the client, and only the client will be able to - * decrypt the token data. - * - * The token data is also hashed with SHA-256 (for future use) and - * stored in the client's session store. The client must decrypt - * the token data, hash it, and return it as a header for verification. - * - * Ideally the client should sign the data and send the signature or - * hash back, but it wont prevent MITM, and for now I think it just - * adds extra overhead for every connection during the HttpEvent.TokenMatches() - * check extension method - */ - - private ref struct TokenGenBuffers + /// <summary> + /// Asynchronously verifies the desired user's password. If the user is not found or the password is not found + /// returns false. Returns true if the user exist's has a valid password hash and matches the supplied password value. + /// </summary> + /// <param name="manager"></param> + /// <param name="userId">The id of the user to check the password against</param> + /// <param name="rawPassword">The raw password of the user to compare hashes against</param> + /// <param name="hashing">The password hashing tools</param> + /// <param name="cancellation">A token to cancel the operation</param> + /// <returns>A task that completes with the value of the password hashing match.</returns> + /// <exception cref="ArgumentNullException"></exception> + public static async Task<bool> VerifyPasswordAsync(this IUserManager manager, string userId, PrivateString rawPassword, IPasswordHashingProvider hashing, CancellationToken cancellation) { - public readonly Span<byte> Buffer { private get; init; } - public readonly Span<byte> SignatureBuffer => Buffer[..64]; - - - - public int ClientPbkWritten; - public readonly Span<byte> ClientPublicKeyBuffer => Buffer.Slice(64, 1024); - public readonly ReadOnlySpan<byte> ClientPbkOutput => ClientPublicKeyBuffer[..ClientPbkWritten]; + _ = userId ?? throw new ArgumentNullException(nameof(userId)); + _ = rawPassword ?? throw new ArgumentNullException(nameof(rawPassword)); + _ = hashing ?? throw new ArgumentNullException(nameof(hashing)); + //Get the user, may be null if the user does not exist + using IUser? user = await manager.GetUserAndPassFromIDAsync(userId, cancellation); - - public int ClientEncBytesWritten; - public readonly Span<byte> ClientEncOutputBuffer => Buffer[(64 + 1024)..]; - public readonly ReadOnlySpan<byte> EncryptedOutput => ClientEncOutputBuffer[..ClientEncBytesWritten]; + return user != null && hashing.Verify(user.PassHash.ToReadOnlySpan(), rawPassword.ToReadOnlySpan()); } - + /// <summary> - /// Computes a random buffer, encrypts it with the client's public key, - /// computes the digest of that key and returns the base64 encoded strings - /// of those components + /// Verifies the user's raw password against the hashed password using the specified + /// <see cref="PasswordHashing"/> instance /// </summary> - /// <param name="base64clientPublicKey">The user's public key credential</param> - /// <param name="base64Digest">The base64 encoded digest of the secret that was encrypted</param> - /// <param name="base64ClientData">The client's user-agent header value</param> - /// <returns>A string representing a unique signed token for a given login context</returns> - /// <exception cref="OutOfMemoryException"></exception> - /// <exception cref="CryptographicException"></exception> - private static void TryGenerateToken(string base64clientPublicKey, out string base64Digest, out string base64ClientData) + /// <param name="user"></param> + /// <param name="rawPassword"></param> + /// <param name="hashing">The <see cref="IPasswordHashingProvider"/> provider instance</param> + /// <returns>True if the password </returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool VerifyPassword(this IUser user, PrivateString rawPassword, IPasswordHashingProvider hashing) { - //Temporary work buffer - using IMemoryHandle<byte> buffer = MemoryUtil.SafeAlloc<byte>(4096, true); - /* - * Create a new token buffer for bin buffers. - * This buffer struct is used to break up - * a single block of memory into individual - * non-overlapping (important!) buffer windows - * for named purposes - */ - TokenGenBuffers tokenBuf = new() - { - Buffer = buffer.Span - }; - //Recover the clients public key from its base64 encoding - if (!Convert.TryFromBase64String(base64clientPublicKey, tokenBuf.ClientPublicKeyBuffer, out tokenBuf.ClientPbkWritten)) - { - throw new InternalBufferOverflowException("Failed to recover the clients RSA public key"); - } - /* - * Fill signature buffer with random data - * this signature will be stored and used to verify - * signed client messages. It will also be encryped - * using the clients RSA keys - */ - RandomHash.GetRandomBytes(tokenBuf.SignatureBuffer); - /* - * Setup a new RSA Crypto provider that is initialized with the clients - * supplied public key. RSA will be used to encrypt the server secret - * that only the client will be able to decrypt for the current connection - */ - using RSA rsa = RSA.Create(); - //Setup rsa from the users public key - rsa.ImportSubjectPublicKeyInfo(tokenBuf.ClientPbkOutput, out _); - //try to encypte output data - if (!rsa.TryEncrypt(tokenBuf.SignatureBuffer, tokenBuf.ClientEncOutputBuffer, RSAEncryptionPadding.OaepSHA256, out tokenBuf.ClientEncBytesWritten)) - { - throw new InternalBufferOverflowException("Failed to encrypt the server secret"); - } - //Compute the digest of the raw server key - base64Digest = ManagedHash.ComputeBase64Hash(tokenBuf.SignatureBuffer, HashAlg.SHA384); - /* - * The client will send a hash of the decrypted key and will be used - * as a comparison to the hash string above ^ - */ - base64ClientData = Convert.ToBase64String(tokenBuf.EncryptedOutput, Base64FormattingOptions.None); + return user.PassHash != null && hashing.Verify(user.PassHash, rawPassword); } /// <summary> - /// Determines if the client sent a token header, and it maches against the current session + /// Verifies a password against its previously encoded hash. /// </summary> - /// <returns>true if the client set the token header, the session is loaded, and the token matches the session, false otherwise</returns> - public static bool TokenMatches(this HttpEntity ev) + /// <param name="provider"></param> + /// <param name="passHash">Previously hashed password</param> + /// <param name="password">Raw password to compare against</param> + /// <returns>True if bytes derrived from password match the hash, false otherwise</returns> + /// <exception cref="NotSupportedException"></exception> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool Verify(this IPasswordHashingProvider provider, PrivateString passHash, PrivateString password) { - //Get the token from the client header, the client should always sent this - string? clientDigest = ev.Server.Headers[LOGIN_TOKEN_HEADER]; - //Make sure a session is loaded - if (!ev.Session.IsSet || ev.Session.IsNew || string.IsNullOrWhiteSpace(clientDigest)) - { - return false; - } - /* - * Alloc buffer to do conversion and zero initial contents incase the - * payload size has been changed. - * - * The buffer just needs to be large enoguh for the size of the hashes - * that are stored in base64 format. - * - * The values in the buffers will be the raw hash of the client's key - * and the stored key sent during initial authorziation. If the hashes - * are equal it should mean that the client must have the private - * key that generated the public key that was sent - */ - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(TokenHashSize * 2, true); - //Slice up buffers - Span<byte> headerBuffer = buffer.Span[..TokenHashSize]; - Span<byte> sessionBuffer = buffer.Span[TokenHashSize..]; - //Convert the header token and the session token - if (Convert.TryFromBase64String(clientDigest, headerBuffer, out int headerTokenLen) - && Convert.TryFromBase64String(ev.Session.Token, sessionBuffer, out int sessionTokenLen)) - { - //Do a fixed time equal (probably overkill, but should not matter too much) - if(CryptographicOperations.FixedTimeEquals(headerBuffer[..headerTokenLen], sessionBuffer[..sessionTokenLen])) - { - return true; - } - } - - /* - * If the token does not match, or cannot be found, check if the client - * has login cookies set, if not remove them. - * - * This does not affect the session, but allows for a web client to update - * its login state if its no-longer logged in - */ - - //Expire login cookie if set - if (ev.Server.RequestCookies.ContainsKey(LOGIN_COOKIE_NAME)) - { - ev.Server.ExpireCookie(LOGIN_COOKIE_NAME, sameSite: CookieSameSite.SameSite); - } - //Expire the LI cookie if set - if (ev.Server.RequestCookies.ContainsKey(LOGIN_COOKIE_IDENTIFIER)) - { - ev.Server.ExpireCookie(LOGIN_COOKIE_IDENTIFIER, sameSite: CookieSameSite.SameSite); - } - - return false; + //Casting PrivateStrings to spans will reference the base string directly + return provider.Verify((ReadOnlySpan<char>)passHash, (ReadOnlySpan<char>)password); } /// <summary> - /// Regenerates the user's login token with the public key stored - /// during initial logon + /// Hashes a specified password, with the initialized pepper, and salted with CNG random bytes. /// </summary> - /// <returns>The base64 of the newly encrypted secret</returns> - public static string? RegenerateClientToken(this HttpEntity ev) + /// <param name="provider"></param> + /// <param name="password">Password to be hashed</param> + /// <exception cref="NotSupportedException"></exception> + /// <returns>A <see cref="PrivateString"/> of the hashed and encoded password</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static PrivateString Hash(this IPasswordHashingProvider provider, PrivateString password) { - if(!ev.Session.IsSet || ev.Session.SessionType != SessionType.Web) - { - return null; - } - //Get the client's stored public key - string clientPublicKey = ev.Session.GetBrowserPubKey(); - //Make sure its set - if (string.IsNullOrWhiteSpace(clientPublicKey)) - { - return null; - } - //Generate a new token using the stored public key - TryGenerateToken(clientPublicKey, out string base64Digest, out string base64ClientData); - //store the token to the user's session - ev.Session.Token = base64Digest; - //Update the last token upgrade time - ev.Session.LastTokenUpgrade(ev.RequestedTimeUtc); - //return the clients encrypted secret - return base64ClientData; + return provider.Hash((ReadOnlySpan<char>)password); } - /// <summary> - /// Tries to encrypt the specified data using the stored public key and store the encrypted data into - /// the output buffer. - /// </summary> - /// <param name="session"></param> - /// <param name="data">Data to encrypt</param> - /// <param name="outputBuffer">The buffer to store encrypted data in</param> - /// <returns> - /// The number of encrypted bytes written to the output buffer, - /// or false (0) if the operation failed, or if no credential is - /// stored. - /// </returns> - /// <exception cref="CryptographicException"></exception> - public static ERRNO TryEncryptClientData(this in SessionInfo session, ReadOnlySpan<byte> data, in Span<byte> outputBuffer) + #endregion + + + + #region Client Auth Extensions + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static IAccountSecurityProvider GetSecProviderOrThrow(this HttpEntity entity) { - if (!session.IsSet) - { - return false; - } - //try to get the public key from the client - string base64PubKey = session.GetBrowserPubKey(); - return TryEncryptClientData(base64PubKey, data, in outputBuffer); + return entity.RequestedRoot.AccountSecurity + ?? throw new NotSupportedException("The processor this connection originated from does not have an account security provider loaded"); } + /// <summary> - /// Tries to encrypt the specified data using the specified public key + /// Determines if the current client has the authroziation level to access a given resource /// </summary> - /// <param name="base64PubKey">A base64 encoded public key used to encrypt client data</param> - /// <param name="data">Data to encrypt</param> - /// <param name="outputBuffer">The buffer to store encrypted data in</param> - /// <returns> - /// The number of encrypted bytes written to the output buffer, - /// or false (0) if the operation failed, or if no credential is - /// specified. - /// </returns> - /// <exception cref="CryptographicException"></exception> - public static ERRNO TryEncryptClientData(ReadOnlySpan<char> base64PubKey, ReadOnlySpan<byte> data, in Span<byte> outputBuffer) + /// <param name="entity"></param> + /// <param name="mode">The authoziation level</param> + /// <returns>True if the connection has the desired authorization status</returns> + /// <exception cref="NotSupportedException"></exception> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsClientAuthorized(this HttpEntity entity, AuthorzationCheckLevel mode = AuthorzationCheckLevel.Critical) { - if (base64PubKey.IsEmpty) - { - return false; - } - //Alloc a buffer for decoding the public key - using UnsafeMemoryHandle<byte> pubKeyBuffer = MemoryUtil.UnsafeAlloc<byte>(PUBLIC_KEY_BUFFER_SIZE, true); - //Decode the public key - ERRNO pbkBytesWritten = VnEncoding.TryFromBase64Chars(base64PubKey, pubKeyBuffer); - //Try to encrypt the data - return pbkBytesWritten ? TryEncryptClientData(pubKeyBuffer.Span[..(int)pbkBytesWritten], data, in outputBuffer) : false; + IAccountSecurityProvider prov = entity.GetSecProviderOrThrow(); + + return prov.IsClientAuthorized(entity, mode); } + /// <summary> - /// Tries to encrypt the specified data using the specified public key + /// Runs necessary operations to grant authorization to the specified user of a given session and user with provided variables /// </summary> - /// <param name="rawPubKey">The raw SKI public key</param> - /// <param name="data">Data to encrypt</param> - /// <param name="outputBuffer">The buffer to store encrypted data in</param> - /// <returns> - /// The number of encrypted bytes written to the output buffer, - /// or false (0) if the operation failed, or if no credential is - /// specified. - /// </returns> + /// <param name="entity">The connection and session to log-in</param> + /// <param name="secInfo">The clients login security information</param> + /// <param name="user">The user to log-in</param> + /// <returns>The encrypted base64 token secret data to send to the client</returns> + /// <exception cref="OutOfMemoryException"></exception> + /// <exception cref="NotSupportedException"></exception> /// <exception cref="CryptographicException"></exception> - public static ERRNO TryEncryptClientData(ReadOnlySpan<byte> rawPubKey, ReadOnlySpan<byte> data, in Span<byte> outputBuffer) + /// <exception cref="InvalidOperationException"></exception> + public static IClientAuthorization GenerateAuthorization(this HttpEntity entity, IClientSecInfo secInfo, IUser user) { - if (rawPubKey.IsEmpty) + _ = secInfo ?? throw new ArgumentNullException(nameof(secInfo)); + + if (!entity.Session.IsSet || entity.Session.SessionType != SessionType.Web) { - return false; + throw new InvalidOperationException("The session is not set or the session is not a web-based session type"); } - //Setup new empty rsa - using RSA rsa = RSA.Create(); - //Import the public key - rsa.ImportSubjectPublicKeyInfo(rawPubKey, out _); - //Encrypt data with OaepSha256 as configured in the browser - return rsa.TryEncrypt(data, outputBuffer, ClientEncryptonPadding, out int bytesWritten) ? bytesWritten : false; - } - /// <summary> - /// Stores the clients public key specified during login - /// </summary> - /// <param name="session"></param> - /// <param name="base64PubKey"></param> - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SetBrowserPubKey(in SessionInfo session, string base64PubKey) => session[CLIENT_PUB_KEY_ENTRY] = base64PubKey; + IAccountSecurityProvider provider = entity.GetSecProviderOrThrow(); - /// <summary> - /// Gets the clients stored public key that was specified during login - /// </summary> - /// <returns>The base64 encoded public key string specified at login</returns> - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string GetBrowserPubKey(this in SessionInfo session) => session[CLIENT_PUB_KEY_ENTRY]; + //Regen the session id + entity.Session.RegenID(); + + //Authorize client + IClientAuthorization auth = provider.AuthorizeClient(entity, secInfo, user); + + //Clear flags + user.FailedLoginCount(0); + + //Store variables + entity.Session.UserID = user.UserID; + entity.Session.Privilages = user.Privilages; + + //Store client id for later use + entity.Session[BROWSER_ID_ENTRY] = secInfo.ClientId; + + //Get the "local" account flag from the user object + bool localAccount = user.IsLocalAccount(); + + //Set local account flag + entity.Session.HasLocalAccount(localAccount); + + //Return the client encrypted data + return auth; + } /// <summary> - /// Stores the login key as a cookie in the current session as long as the session exists - /// </summary>/ - /// <param name="ev">The event to log-in</param> - /// <param name="localAccount">Does the session belong to a local user account</param> + /// Generates a client authorization from the supplied security info + /// using the default <see cref="IAccountSecurityProvider"/> and + /// stored the required variables in the <paramref name="response"/> + /// response <see cref="WebMessage"/> + /// </summary> + /// <param name="entity"></param> + /// <param name="secInfo">The client's <see cref="IClientSecInfo"/> used to authorize the client</param> + /// <param name="user">The user requesting the authenticated use</param> + /// <param name="response">The response to store variables in</param> + /// <exception cref="NotSupportedException"></exception> [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SetLogin(this HttpEntity ev, bool? localAccount = null) + public static void GenerateAuthorization(this HttpEntity entity, IClientSecInfo secInfo, IUser user, WebMessage response) { - //Make sure the session is loaded - if (!ev.Session.IsSet) - { - return; - } - string loginString = RandomHash.GetRandomBase64(LOGIN_COOKIE_SIZE); - //Set login cookie and session login hash - ev.Server.SetCookie(LOGIN_COOKIE_NAME, loginString, "", "/", LoginCookieLifespan, CookieSameSite.SameSite, true, true); - ev.Session.LoginHash = loginString; - //If not set get from session storage - localAccount ??= ev.Session.HasLocalAccount(); - //Set the client identifier cookie to a value indicating a local account - ev.Server.SetCookie(LOGIN_COOKIE_IDENTIFIER, localAccount.Value ? "1" : "2", "", "/", LoginCookieLifespan, CookieSameSite.SameSite, false, true); + //Authorize the client + IClientAuthorization auth = GenerateAuthorization(entity, secInfo, user); + + //Set client token + response.Token = auth.SecurityToken.ClientToken; } /// <summary> - /// Invalidates the login status of the current connection and session (if session is loaded) + /// Regenerates the client authorization if the client has a currently valid authorization /// </summary> + /// <param name="entity"></param> + /// <returns>The new <see cref="IClientAuthorization"/> for the regenerated credentials</returns> + /// <exception cref="NotSupportedException"></exception> [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void InvalidateLogin(this HttpEntity ev) + public static IClientAuthorization ReAuthorizeClient(this HttpEntity entity) { - //Expire the login cookie - ev.Server.ExpireCookie(LOGIN_COOKIE_NAME, sameSite: CookieSameSite.SameSite, secure: true); - //Expire the identifier cookie - ev.Server.ExpireCookie(LOGIN_COOKIE_IDENTIFIER, sameSite: CookieSameSite.SameSite, secure: true); - if (ev.Session.IsSet) - { - //Invalidate the session - ev.Session.Invalidate(); - } + //Get default provider + IAccountSecurityProvider prov = entity.GetSecProviderOrThrow(); + + //Re-authorize the client + return prov.ReAuthorizeClient(entity); } /// <summary> - /// Determines if the current session login cookie matches the value stored in the current session (if the session is loaded) + /// Regenerates the client authorization if the client has a currently valid authorization /// </summary> - /// <returns>True if the session is active, the cookie was properly received, and the cookie value matches the session. False otherwise</returns> - public static bool LoginCookieMatches(this HttpEntity ev) - { - //Sessions must be loaded - if (!ev.Session.IsSet) - { - return false; - } - //Try to get the login string from the request cookies - if (!ev.Server.RequestCookies.TryGetNonEmptyValue(LOGIN_COOKIE_NAME, out string? liCookie)) - { - return false; - } - /* - * Alloc buffer to do conversion and zero initial contents incase the - * payload size has been changed. - * - * Since the cookie size and the local copy should be the same size - * and equal to the LOGIN_COOKIE_SIZE constant, the buffer size should - * be 2 * LOGIN_COOKIE_SIZE, and it can be split in half and shared - * for both conversions - */ - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(2 * LOGIN_COOKIE_SIZE, true); - //Slice up buffers - Span<byte> cookieBuffer = buffer.Span[..LOGIN_COOKIE_SIZE]; - Span<byte> sessionBuffer = buffer.Span.Slice(LOGIN_COOKIE_SIZE, LOGIN_COOKIE_SIZE); - //Convert cookie and session hash value - if (Convert.TryFromBase64String(liCookie, cookieBuffer, out _) - && Convert.TryFromBase64String(ev.Session.LoginHash, sessionBuffer, out _)) - { - //Do a fixed time equal (probably overkill, but should not matter too much) - if(CryptographicOperations.FixedTimeEquals(cookieBuffer, sessionBuffer)) - { - //If the user is "logged in" and the request is using the POST method, then we can update the cookie - if(ev.Server.Method == HttpMethod.POST && ev.Session.Created.Add(RegenIdPeriod) < ev.RequestedTimeUtc) - { - //Regen login token - ev.SetLogin(); - ev.Session.RegenID(); - } - - return true; - } - } - return false; + /// <param name="entity"></param> + /// <param name="response">The response message to return to the client</param> + /// <exception cref="NotSupportedException"></exception> + /// <returns>The new <see cref="IClientAuthorization"/> for the regenerated credentials</returns> + public static void ReAuthorizeClient(this HttpEntity entity, WebMessage response) + { + IAccountSecurityProvider prov = entity.GetSecProviderOrThrow(); + + //Re-authorize the client + IClientAuthorization auth = prov.ReAuthorizeClient(entity); + + //Store the client token in response message + response.Token = auth.SecurityToken.ClientToken; + + //Regen session id also + entity.Session.RegenID(); } - + /// <summary> - /// Determines if the client's login cookies need to be updated - /// to reflect its state with the current session's state - /// for the client + /// Attempts to encrypt the supplied data with session stored client information. The user must + /// be authorized /// </summary> - /// <param name="ev"></param> - public static void ReconcileCookies(this HttpEntity ev) + /// <param name="entity"></param> + /// <param name="data">The data to encrypt for the current client</param> + /// <param name="output">The buffer to write encypted data to</param> + /// <exception cref="NotSupportedException"></exception> + /// <returns>The number of bytes encrypted and written to the output buffer</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ERRNO TryEncryptClientData(this HttpEntity entity, ReadOnlySpan<byte> data, Span<byte> output) { - //Only handle cookies if session is loaded and is a web based session - if (!ev.Session.IsSet || ev.Session.SessionType != SessionType.Web) - { - return; - } - if (ev.Session.IsNew) - { - //If either login cookies are set on a new session, clear them - if (ev.Server.RequestCookies.ContainsKey(LOGIN_COOKIE_NAME) || ev.Server.RequestCookies.ContainsKey(LOGIN_COOKIE_IDENTIFIER)) - { - //Expire the login cookie - ev.Server.ExpireCookie(LOGIN_COOKIE_NAME, sameSite:CookieSameSite.SameSite, secure:true); - //Expire the identifier cookie - ev.Server.ExpireCookie(LOGIN_COOKIE_IDENTIFIER, sameSite: CookieSameSite.SameSite, secure: true); - } - } - //If the session is not supposed to be logged in, clear the login cookies if they were set - else if (string.IsNullOrEmpty(ev.Session.LoginHash)) + //Confirm session is loaded + if(!entity.Session.IsSet || entity.Session.IsNew) { - //If one of either cookie is not set - if (ev.Server.RequestCookies.ContainsKey(LOGIN_COOKIE_NAME)) - { - //Expire the login cookie - ev.Server.ExpireCookie(LOGIN_COOKIE_NAME, sameSite: CookieSameSite.SameSite, secure: true); - } - if (ev.Server.RequestCookies.ContainsKey(LOGIN_COOKIE_IDENTIFIER)) - { - //Expire the identifier cookie - ev.Server.ExpireCookie(LOGIN_COOKIE_IDENTIFIER, sameSite: CookieSameSite.SameSite, secure: true); - } + return false; } + + //Use the default sec provider + IAccountSecurityProvider prov = entity.GetSecProviderOrThrow(); + return prov.TryEncryptClientData(entity, data, output); } /// <summary> - /// Gets the last time the session token was set + /// Attempts to encrypt the supplied data with session stored client information. The user must + /// be authorized /// </summary> - /// <param name="session"></param> - /// <returns>The last time the token was updated/generated, or <see cref="DateTimeOffset.MinValue"/> if not set</returns> - public static DateTimeOffset LastTokenUpgrade(this in SessionInfo session) + /// <param name="entity"></param> + /// <param name="secInfo">Used for unauthorized connections to encrypt client data based on client security info</param> + /// <param name="data">The data to encrypt for the current client</param> + /// <param name="output">The buffer to write encypted data to</param> + /// <returns>The number of bytes encrypted and written to the output buffer</returns> + /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="NotSupportedException"></exception> + public static ERRNO TryEncryptClientData(this HttpEntity entity, IClientSecInfo secInfo, ReadOnlySpan<byte> data, Span<byte> output) { - //Get the serialized time value - string timeString = session[TOKEN_UPDATE_TIME_ENTRY]; - return long.TryParse(timeString, out long time) ? DateTimeOffset.FromUnixTimeSeconds(time) : DateTimeOffset.MinValue; - } + _ = secInfo ?? throw new ArgumentNullException(nameof(secInfo)); - /// <summary> - /// Updates the last time the session token was set - /// </summary> - /// <param name="session"></param> - /// <param name="updated">The UTC time the last token was set</param> - private static void LastTokenUpgrade(this in SessionInfo session, DateTimeOffset updated) - => session[TOKEN_UPDATE_TIME_ENTRY] = updated.ToUnixTimeSeconds().ToString(); + //Use the default sec provider + IAccountSecurityProvider prov = entity.GetSecProviderOrThrow(); + return prov.TryEncryptClientData(secInfo, data, output); + } /// <summary> - /// Stores the browser's id during a login process + /// Invalidates the login status of the current connection and session (if session is loaded) /// </summary> - /// <param name="session"></param> - /// <param name="browserId">Browser id value to store</param> + /// <exception cref="NotSupportedException"></exception> [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SetBrowserID(in SessionInfo session, string browserId) => session[BROWSER_ID_ENTRY] = browserId; + public static void InvalidateLogin(this HttpEntity entity) + { + //Invalidate against the sec provider + IAccountSecurityProvider prov = entity.GetSecProviderOrThrow(); + + prov.InvalidateLogin(entity); + + //Invalidate the session also + entity.Session.Invalidate(); + } /// <summary> /// Gets the current browser's id if it was specified during login process @@ -728,7 +457,8 @@ namespace VNLib.Plugins.Essentials.Accounts /// <param name="session"></param> /// <param name="value">True for a local account, false otherwise</param> [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void HasLocalAccount(this in SessionInfo session, bool value) => session[LOCAL_ACCOUNT_ENTRY] = value ? "1" : null; + public static void HasLocalAccount(this in SessionInfo session, bool value) => session[LOCAL_ACCOUNT_ENTRY] = value ? "1" : null; + /// <summary> /// Gets a value indicating if the session belongs to a local user account /// </summary> @@ -739,91 +469,6 @@ namespace VNLib.Plugins.Essentials.Accounts #endregion - #region Client Challenge - - /* - * Generates a secret that is used to compute the unique hmac digest of the - * current user's password. The digest is stored in the current session - * and used to compare future requests that require password re-authentication. - * The client will compute the digest of the user's password and send the digest - * instead of the user's password - */ - - /// <summary> - /// Generates a new password challenge for the current session and specified password - /// </summary> - /// <param name="session"></param> - /// <param name="password">The user's password to compute the hash of</param> - /// <returns>The raw derrivation key to send to the client</returns> - public static byte[] GenPasswordChallenge(this in SessionInfo session, PrivateString password) - { - ReadOnlySpan<char> rawPass = password; - //Calculate the password buffer size required - int passByteCount = Encoding.UTF8.GetByteCount(rawPass); - //Allocate the buffer - using UnsafeMemoryHandle<byte> bufferHandle = MemoryUtil.UnsafeAlloc<byte>(passByteCount + 64, true); - //Slice buffers - Span<byte> utf8PassBytes = bufferHandle.Span[..passByteCount]; - Span<byte> hashBuffer = bufferHandle.Span[passByteCount..]; - //Encode the password into the buffer - _ = Encoding.UTF8.GetBytes(rawPass, utf8PassBytes); - try - { - //Get random secret buffer - byte[] secretKey = RandomHash.GetRandomBytes(SESSION_CHALLENGE_SIZE); - //Compute the digest - int count = HMACSHA512.HashData(secretKey, utf8PassBytes, hashBuffer); - //Store the user's password digest - session[CHALLENGE_HMAC_ENTRY] = VnEncoding.ToBase32String(hashBuffer[..count], false); - return secretKey; - } - finally - { - //Wipe buffer - RandomHash.GetRandomBytes(utf8PassBytes); - } - } - /// <summary> - /// Verifies the stored unique digest of the user's password against - /// the client derrived password - /// </summary> - /// <param name="session"></param> - /// <param name="base64PasswordDigest">The base64 client derrived digest of the user's password to verify</param> - /// <returns>True if formatting was correct and the derrived passwords match, false otherwise</returns> - /// <exception cref="FormatException"></exception> - public static bool VerifyChallenge(this in SessionInfo session, ReadOnlySpan<char> base64PasswordDigest) - { - string base32Digest = session[CHALLENGE_HMAC_ENTRY]; - if (string.IsNullOrWhiteSpace(base32Digest)) - { - return false; - } - int bufSize = base32Digest.Length + base64PasswordDigest.Length; - //Alloc buffer - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(bufSize); - //Split buffers - Span<byte> localBuf = buffer.Span[..base32Digest.Length]; - Span<byte> passBuf = buffer.Span[base32Digest.Length..]; - //Recover the stored base32 digest - ERRNO count = VnEncoding.TryFromBase32Chars(base32Digest, localBuf); - if (!count) - { - return false; - } - //Recover base64 bytes - if(!Convert.TryFromBase64Chars(base64PasswordDigest, passBuf, out int passBytesWritten)) - { - return false; - } - //Trim buffers - localBuf = localBuf[..(int)count]; - passBuf = passBuf[..passBytesWritten]; - //Compare and return - return CryptographicOperations.FixedTimeEquals(passBuf, localBuf); - } - - #endregion - #region Privilage Extensions /// <summary> /// Compares the users privilage level against the specified level diff --git a/lib/Plugins.Essentials/src/Accounts/AuthorzationCheckLevel.cs b/lib/Plugins.Essentials/src/Accounts/AuthorzationCheckLevel.cs new file mode 100644 index 0000000..aa09bf4 --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/AuthorzationCheckLevel.cs @@ -0,0 +1,56 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: AuthorzationCheckLevel.cs +* +* AuthorzationCheckLevel.cs is part of VNLib.Plugins.Essentials which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +#nullable enable + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// <summary> + /// Specifies how critical the security check is for a user to access + /// a given resource + /// </summary> + public enum AuthorzationCheckLevel + { + /// <summary> + /// No authorization check is required. + /// </summary> + None, + /// <summary> + /// Is there any information that the client may have authorization. NOTE: Not a security check! + /// </summary> + Any, + /// <summary> + /// The authorization check is not considered criticial, just a basic confirmation + /// that the user should be logged it, but does not need to access secure + /// resources. + /// </summary> + Medium, + /// <summary> + /// The a full authorization check is required as the user may access + /// secure resouces. + /// </summary> + Critical + } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/ClientSecurityToken.cs b/lib/Plugins.Essentials/src/Accounts/ClientSecurityToken.cs new file mode 100644 index 0000000..0d4aa58 --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/ClientSecurityToken.cs @@ -0,0 +1,43 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: ClientSecurityToken.cs +* +* ClientSecurityToken.cs is part of VNLib.Plugins.Essentials which is part +* of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +#nullable enable + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// <summary> + /// A structure that contains the client/server information + /// for client/server authorization + /// </summary> + /// <param name="ClientToken"> + /// The public portion of the token to send to the client + /// </param> + /// <param name="ServerToken"> + /// The secret portion of the token that is to be + /// stored on the server (usually in the client's session) + /// </param> + public readonly record struct ClientSecurityToken(string ClientToken, string ServerToken) + { } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/IAccountSecurityProvider.cs b/lib/Plugins.Essentials/src/Accounts/IAccountSecurityProvider.cs new file mode 100644 index 0000000..c30796b --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/IAccountSecurityProvider.cs @@ -0,0 +1,90 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IAccountSecurityProvider.cs +* +* IAccountSecurityProvider.cs is part of VNLib.Plugins.Essentials which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; + +using VNLib.Utils; +using VNLib.Plugins.Essentials.Users; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// <summary> + /// Provides account security to client connections. Providing authoirzation, + /// verification, and client data encryption. + /// </summary> + public interface IAccountSecurityProvider + { + /// <summary> + /// Generates a new authorization for the connection with its client security information + /// </summary> + /// <param name="entity">The connection to authorize</param> + /// <param name="clientInfo">The client security information required for authorization</param> + /// <param name="user">The user object to authorize the connection for</param> + /// <returns>The new authorization information for the connection</returns> + IClientAuthorization AuthorizeClient(HttpEntity entity, IClientSecInfo clientInfo, IUser user); + + /// <summary> + /// Regenerates the client's authorization status for a currently logged-in user + /// </summary> + /// <param name="entity">The connection to re-authorize</param> + /// <returns>The new <see cref="IClientAuthorization"/> containing the new authorization information</returns> + IClientAuthorization ReAuthorizeClient(HttpEntity entity); + + /// <summary> + /// Determines if the connection is considered authorized for the desired + /// security level + /// </summary> + /// <param name="entity">The connection to determine the status of</param> + /// <param name="level">The authorziation level to check for</param> + /// <returns>True if the given connection meets the desired authorzation status</returns> + bool IsClientAuthorized(HttpEntity entity, AuthorzationCheckLevel level); + + /// <summary> + /// Encryptes data using the stored client's authorization information. + /// </summary> + /// <param name="entity">The connection to encrypt data for</param> + /// <param name="data">The data to encrypt</param> + /// <param name="outputBuffer">The buffer to write the encrypted data to</param> + /// <returns>The number of bytes written to the output buffer, or o/false if the data could not be encrypted</returns> + ERRNO TryEncryptClientData(HttpEntity entity, ReadOnlySpan<byte> data, Span<byte> outputBuffer); + + /// <summary> + /// Attempts a one-time encryption of client data for a non-authorized user + /// based on the client's <see cref="IClientSecInfo"/> data. + /// </summary> + /// <param name="clientSecInfo">The client's <see cref="IClientSecInfo"/> credentials used to encrypt the message</param> + /// <param name="data">The data to encrypt</param> + /// <param name="outputBuffer">The output buffer to write encrypted data to</param> + /// <returns>The number of bytes written to the output buffer, 0/false if the operation failed</returns> + ERRNO TryEncryptClientData(IClientSecInfo clientSecInfo, ReadOnlySpan<byte> data, Span<byte> outputBuffer); + + /// <summary> + /// Invalidates a logged in connection + /// </summary> + /// <param name="entity">The connection to invalidate the login status of</param> + void InvalidateLogin(HttpEntity entity); + } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/IClientAuthorization.cs b/lib/Plugins.Essentials/src/Accounts/IClientAuthorization.cs new file mode 100644 index 0000000..02bc96e --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/IClientAuthorization.cs @@ -0,0 +1,45 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IClientAuthorization.cs +* +* IClientAuthorization.cs is part of VNLib.Plugins.Essentials which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +#nullable enable + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// <summary> + /// Contains the client's minimum authorization variables + /// </summary> + public interface IClientAuthorization + { + /// <summary> + /// A security token that may be set as a cookie or used + /// </summary> + string? LoginSecurityString { get; } + + /// <summary> + /// The clients security token information + /// </summary> + ClientSecurityToken SecurityToken { get; } + } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/IClientSecInfo.cs b/lib/Plugins.Essentials/src/Accounts/IClientSecInfo.cs new file mode 100644 index 0000000..6990191 --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/IClientSecInfo.cs @@ -0,0 +1,46 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: AccountUtil.cs +* +* AccountUtil.cs is part of VNLib.Plugins.Essentials which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +#nullable enable + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// <summary> + /// Exposed the required security information for a <see cref="IAccountSecurityProvider"/> + /// to authorized a connection. + /// </summary> + public interface IClientSecInfo + { + /// <summary> + /// The clients public-key + /// </summary> + string PublicKey { get; } + + /// <summary> + /// The unique id the client provided to this server + /// </summary> + string ClientId { get; } + } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/IPasswordHashingProvider.cs b/lib/Plugins.Essentials/src/Accounts/IPasswordHashingProvider.cs new file mode 100644 index 0000000..fc45727 --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/IPasswordHashingProvider.cs @@ -0,0 +1,80 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IPasswordHashingProvider.cs +* +* IPasswordHashingProvider.cs is part of VNLib.Plugins.Essentials which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; + +using VNLib.Utils; +using VNLib.Utils.Memory; + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// <summary> + /// Represents a common abstraction for password hashing providers/libraries + /// </summary> + public interface IPasswordHashingProvider + { + /// <summary> + /// Verifies a password against its previously encoded hash. + /// </summary> + /// <param name="passHash">Previously hashed password</param> + /// <param name="password">Raw password to compare against</param> + /// <returns>true if bytes derrived from password match the hash, false otherwise</returns> + /// <exception cref="NotSupportedException"></exception> + bool Verify(ReadOnlySpan<char> passHash, ReadOnlySpan<char> password); + + /// <summary> + /// Verifies a password against its previously encoded hash. + /// </summary> + /// <param name="passHash">Previously hashed password in binary</param> + /// <param name="password">Raw password to compare against the hash</param> + /// <returns>true if bytes derrived from password match the hash, false otherwise</returns> + /// <exception cref="NotSupportedException"></exception> + bool Verify(ReadOnlySpan<byte> passHash, ReadOnlySpan<byte> password); + + /// <summary> + /// Hashes the specified character encoded password to it's secured hashed form. + /// </summary> + /// <param name="password">The character encoded password to encrypt</param> + /// <returns>A <see cref="PrivateString"/> containing the new password hash.</returns> + /// <exception cref="NotSupportedException"></exception> + PrivateString Hash(ReadOnlySpan<char> password); + + /// <summary> + /// Hashes the specified binary encoded password to it's secured hashed form. + /// </summary> + /// <param name="password">The binary encoded password to encrypt</param> + /// <returns>A <see cref="PrivateString"/> containing the new password hash.</returns> + /// <exception cref="NotSupportedException"></exception> + PrivateString Hash(ReadOnlySpan<byte> password); + + /// <summary> + /// Exposes a lower level for producing a password hash and writing it to the output buffer + /// </summary> + /// <param name="password">The raw password to encrypt</param> + /// <param name="hashOutput">The output buffer to write encoded data into</param> + /// <returns>The number of bytes written to the hash buffer, or 0/false if the hashing operation failed</returns> + /// <exception cref="NotSupportedException"></exception> + ERRNO Hash(ReadOnlySpan<byte> password, Span<byte> hashOutput); + } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/LoginMessage.cs b/lib/Plugins.Essentials/src/Accounts/LoginMessage.cs index ebc616e..96bf261 100644 --- a/lib/Plugins.Essentials/src/Accounts/LoginMessage.cs +++ b/lib/Plugins.Essentials/src/Accounts/LoginMessage.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -36,7 +36,7 @@ namespace VNLib.Plugins.Essentials.Accounts /// NOTE: This class derrives from <see cref="PrivateStringManager"/> /// and should be disposed properly /// </remarks> - public class LoginMessage : PrivateStringManager + public class LoginMessage : PrivateStringManager, IClientSecInfo { /// <summary> /// A property @@ -80,7 +80,8 @@ namespace VNLib.Plugins.Essentials.Accounts /// The clients browser id if shared /// </summary> [JsonPropertyName("clientid")] - public string ClientID { get; set; } + public string ClientId { get; set; } + /// <summary> /// Initailzies a new <see cref="LoginMessage"/> and its parent <see cref="PrivateStringManager"/> /// base @@ -98,5 +99,10 @@ namespace VNLib.Plugins.Essentials.Accounts /// or access to <see cref="Password"/> will throw /// </remarks> protected LoginMessage(int protectedElementSize = 1) : base(protectedElementSize) { } + + /* + * Support client security info + */ + string IClientSecInfo.PublicKey => ClientPublicKey; } }
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/PasswordChallengeResult.cs b/lib/Plugins.Essentials/src/Accounts/PasswordChallengeResult.cs new file mode 100644 index 0000000..3ad05ab --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/PasswordChallengeResult.cs @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: PasswordChallengeResult.cs +* +* PasswordChallengeResult.cs is part of VNLib.Plugins.Essentials which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// <summary> + /// A password pased client/server challenge + /// </summary> + /// <param name="ClientData">The client portion of the password based challenge</param> + /// <param name="ServerData">The server potion of the password based challenge</param> + public readonly record struct PasswordChallengeResult(string ClientData, string ServerData) + { } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/PasswordHashing.cs b/lib/Plugins.Essentials/src/Accounts/PasswordHashing.cs index 553b41c..db5b309 100644 --- a/lib/Plugins.Essentials/src/Accounts/PasswordHashing.cs +++ b/lib/Plugins.Essentials/src/Accounts/PasswordHashing.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -31,14 +31,17 @@ using VNLib.Utils.Memory; namespace VNLib.Plugins.Essentials.Accounts { + /// <summary> /// Provides a structured password hashing system implementing the <seealso cref="VnArgon2"/> library /// with fixed time comparison /// </summary> - public sealed class PasswordHashing + public sealed class PasswordHashing : IPasswordHashingProvider { + private const int STACK_MAX_BUFF_SIZE = 64; + private readonly ISecretProvider _secret; - + private readonly uint TimeCost; private readonly uint MemoryCost; private readonly uint HashLen; @@ -71,53 +74,50 @@ namespace VNLib.Plugins.Essentials.Accounts SaltLen = saltLen; Parallelism = parallism < 1 ? (uint)Environment.ProcessorCount : parallism; } - - /// <summary> - /// Verifies a password against its previously encoded hash. - /// </summary> - /// <param name="passHash">Previously hashed password</param> - /// <param name="password">Raw password to compare against</param> - /// <returns>true if bytes derrived from password match the hash, false otherwise</returns> - /// <exception cref="FormatException"></exception> - /// <exception cref="VnArgon2Exception"></exception> - /// <exception cref="VnArgon2PasswordFormatException"></exception> - public bool Verify(PrivateString passHash, PrivateString password) - { - //Casting PrivateStrings to spans will reference the base string directly - return Verify((ReadOnlySpan<char>)passHash, (ReadOnlySpan<char>)password); - } - /// <summary> - /// Verifies a password against its previously encoded hash. - /// </summary> - /// <param name="passHash">Previously hashed password</param> - /// <param name="password">Raw password to compare against</param> - /// <returns>true if bytes derrived from password match the hash, false otherwise</returns> - /// <exception cref="FormatException"></exception> - /// <exception cref="VnArgon2Exception"></exception> - /// <exception cref="VnArgon2PasswordFormatException"></exception> + + ///<inheritdoc/> + ///<exception cref="VnArgon2Exception"></exception> + ///<exception cref="VnArgon2PasswordFormatException"></exception> public bool Verify(ReadOnlySpan<char> passHash, ReadOnlySpan<char> password) { if(passHash.IsEmpty || password.IsEmpty) { return false; } - //alloc secret buffer - using UnsafeMemoryHandle<byte> secretBuffer = MemoryUtil.UnsafeAlloc<byte>(_secret.BufferSize, true); + + if(_secret.BufferSize < STACK_MAX_BUFF_SIZE) + { + //Alloc stack buffer + Span<byte> secretBuffer = stackalloc byte[STACK_MAX_BUFF_SIZE]; + + return VerifyInternal(passHash, password, secretBuffer); + } + else + { + //Alloc heap buffer + using UnsafeMemoryHandle<byte> secretBuffer = MemoryUtil.UnsafeAlloc<byte>(_secret.BufferSize, true); + + return VerifyInternal(passHash, password, secretBuffer); + } + } + + private bool VerifyInternal(ReadOnlySpan<char> passHash, ReadOnlySpan<char> password, Span<byte> secretBuffer) + { try { //Get the secret from the callback - ERRNO count = _secret.GetSecret(secretBuffer.Span); + ERRNO count = _secret.GetSecret(secretBuffer); //Verify - return VnArgon2.Verify2id(password, passHash, secretBuffer.Span[..(int)count]); + return VnArgon2.Verify2id(password, passHash, secretBuffer[..(int)count]); } finally { //Erase secret buffer - MemoryUtil.InitializeBlock(secretBuffer.Span); + MemoryUtil.InitializeBlock(secretBuffer); } } - + /// <summary> /// Verifies a password against its hash. Partially exposes the Argon2 api. /// </summary> @@ -136,19 +136,8 @@ namespace VNLib.Plugins.Essentials.Accounts //Compare the hashed password to the specified hash and return results return CryptographicOperations.FixedTimeEquals(hash, hashBuf.Span); } - - /// <summary> - /// Hashes a specified password, with the initialized pepper, and salted with CNG random bytes. - /// </summary> - /// <param name="password">Password to be hashed</param> - /// <exception cref="VnArgon2Exception"></exception> - /// <returns>A <see cref="PrivateString"/> of the hashed and encoded password</returns> - public PrivateString Hash(PrivateString password) => Hash((ReadOnlySpan<char>)password); - /// <summary> - /// Hashes a specified password, with the initialized pepper, and salted with CNG random bytes. - /// </summary> - /// <param name="password">Password to be hashed</param> + /// <inheritdoc/> /// <exception cref="VnArgon2Exception"></exception> /// <returns>A <see cref="PrivateString"/> of the hashed and encoded password</returns> public PrivateString Hash(ReadOnlySpan<char> password) @@ -175,11 +164,8 @@ namespace VNLib.Plugins.Essentials.Accounts MemoryUtil.InitializeBlock(buffer.Span); } } - - /// <summary> - /// Hashes a specified password, with the initialized pepper, and salted with a CNG random bytes. - /// </summary> - /// <param name="password">Password to be hashed</param> + + /// <inheritdoc/> /// <exception cref="VnArgon2Exception"></exception> /// <returns>A <see cref="PrivateString"/> of the hashed and encoded password</returns> public PrivateString Hash(ReadOnlySpan<byte> password) @@ -231,5 +217,76 @@ namespace VNLib.Plugins.Essentials.Accounts MemoryUtil.InitializeBlock(secretBuffer.Span); } } + + /// <summary> + /// NOT SUPPORTED! Use <see cref="Verify(ReadOnlySpan{byte}, ReadOnlySpan{byte}, ReadOnlySpan{byte})"/> + /// instead to specify the salt that was used to encypt the original password + /// </summary> + /// <param name="passHash"></param> + /// <param name="password"></param> + /// <exception cref="NotSupportedException"></exception> + public bool Verify(ReadOnlySpan<byte> passHash, ReadOnlySpan<byte> password) + { + throw new NotSupportedException(); + } + + ///<inheritdoc/> + ///<exception cref="VnArgon2Exception"></exception> + public ERRNO Hash(ReadOnlySpan<byte> password, Span<byte> hashOutput) + { + //Calc the min buffer size + int minBufferSize = SaltLen + _secret.BufferSize + (int)HashLen; + + //Alloc heap buffer + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage<byte>(minBufferSize, true); + try + { + //Segment the buffer + HashBufferSegments segments = new(buffer.Span, _secret.BufferSize, SaltLen, (int)HashLen); + + //Fill the buffer with random bytes + RandomHash.GetRandomBytes(segments.SaltBuffer); + + //recover the secret + ERRNO count = _secret.GetSecret(segments.SecretBuffer); + + //Hash the password in binary and write the secret to the binary buffer + VnArgon2.Hash2id(password, segments.SaltBuffer, segments.SecretBuffer[..(int)count], segments.HashBuffer, TimeCost, MemoryCost, Parallelism); + + //Hash size is the desired hash size + return new((int)HashLen); + } + finally + { + MemoryUtil.InitializeBlock(buffer.Span); + } + } + + private readonly ref struct HashBufferSegments + { + public readonly Span<byte> SaltBuffer; + + public readonly Span<byte> SecretBuffer; + + public readonly Span<byte> HashBuffer; + + public HashBufferSegments(Span<byte> buffer, int secretSize, int saltSize, int hashSize) + { + //Salt buffer is begining segment + SaltBuffer = buffer[..saltSize]; + + //Shift to end of salt buffer + buffer = buffer[saltSize..]; + + //Store secret buffer + SecretBuffer = buffer[..secretSize]; + + //Shift to end of secret buffer + buffer = buffer[secretSize..]; + + //Store remaining size as hash buffer + HashBuffer = buffer[..hashSize]; + } + } } }
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Endpoints/ProtectedWebEndpoint.cs b/lib/Plugins.Essentials/src/Endpoints/ProtectedWebEndpoint.cs index bced960..c529028 100644 --- a/lib/Plugins.Essentials/src/Endpoints/ProtectedWebEndpoint.cs +++ b/lib/Plugins.Essentials/src/Endpoints/ProtectedWebEndpoint.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -36,6 +36,12 @@ namespace VNLib.Plugins.Essentials.Endpoints /// </summary> public abstract class ProtectedWebEndpoint : UnprotectedWebEndpoint { + /// <summary> + /// Gets the minium <see cref="AuthorzationCheckLevel"/> required by a client to + /// access this endpoint + /// </summary> + protected virtual AuthorzationCheckLevel AuthLevel { get; } = AuthorzationCheckLevel.Critical; + ///<inheritdoc/> protected override ERRNO PreProccess(HttpEntity entity) { @@ -43,14 +49,16 @@ namespace VNLib.Plugins.Essentials.Endpoints { return false; } - //The loggged in flag must be set, and the token must also match - if (!entity.LoginCookieMatches() || !entity.TokenMatches()) + + //Require full authorization to the resource + if (!entity.IsClientAuthorized(AuthLevel)) { //Return unauthorized status entity.CloseResponse(HttpStatusCode.Unauthorized); //A return value less than 0 signals a virtual skip event return -1; } + //Continue return true; } diff --git a/lib/Plugins.Essentials/src/EventProcessor.cs b/lib/Plugins.Essentials/src/EventProcessor.cs index d34cf95..ccaa1a1 100644 --- a/lib/Plugins.Essentials/src/EventProcessor.cs +++ b/lib/Plugins.Essentials/src/EventProcessor.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -38,18 +38,20 @@ using VNLib.Utils.Resources; using VNLib.Plugins.Essentials.Content; using VNLib.Plugins.Essentials.Sessions; using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Accounts; #nullable enable namespace VNLib.Plugins.Essentials { + /// <summary> /// Provides an abstract base implementation of <see cref="IWebRoot"/> /// that breaks down simple processing procedures, routing, and session /// loading. /// </summary> - public abstract class EventProcessor : IWebRoot + public abstract class EventProcessor : IWebRoot, IWebProcessor { private static readonly AsyncLocal<EventProcessor?> _currentProcessor = new(); @@ -70,6 +72,9 @@ namespace VNLib.Plugins.Essentials /// </summary> public abstract IEpProcessingOptions Options { get; } + ///<inheritdoc/> + public abstract IReadOnlyDictionary<string, Redirect> Redirects { get; } + /// <summary> /// Event log provider /// </summary> @@ -112,23 +117,10 @@ namespace VNLib.Plugins.Essentials /// <param name="chosenRoutine">The selected file processing routine for the given request</param> public abstract void PostProcessFile(HttpEntity entity, in FileProcessArgs chosenRoutine); - #region redirects - ///<inheritdoc/> - public IReadOnlyDictionary<string, Redirect> Redirects => _redirects; - - private Dictionary<string, Redirect> _redirects = new(); + #region security - /// <summary> - /// Initializes 301 redirects table from a collection of redirects - /// </summary> - /// <param name="redirs">A collection of redirects</param> - public void SetRedirects(IEnumerable<Redirect> redirs) - { - //To dictionary - Dictionary<string, Redirect> r = redirs.ToDictionary(r => r.Url, r => r, StringComparer.OrdinalIgnoreCase); - //Swap - _ = Interlocked.Exchange(ref _redirects, r); - } + ///<inheritdoc/> + public abstract IAccountSecurityProvider AccountSecurity { get; } #endregion @@ -204,7 +196,7 @@ namespace VNLib.Plugins.Essentials /// A "lookup table" that represents virtual endpoints to be processed when an /// incomming connection matches its path parameter /// </summary> - private Dictionary<string, IVirtualEndpoint<HttpEntity>> VirtualEndpoints = new(); + private IReadOnlyDictionary<string, IVirtualEndpoint<HttpEntity>> VirtualEndpoints = new Dictionary<string, IVirtualEndpoint<HttpEntity>>(); /* @@ -271,7 +263,7 @@ namespace VNLib.Plugins.Essentials { _ = eps ?? throw new ArgumentNullException(nameof(eps)); //Call remove on path - RemoveVirtualEndpoint(eps.Select(static s => s.Path).ToArray()); + RemoveEndpoint(eps.Select(static s => s.Path).ToArray()); } /// <summary> @@ -281,9 +273,10 @@ namespace VNLib.Plugins.Essentials /// <exception cref="ArgumentException"></exception> /// <exception cref="ArgumentNullException"></exception> /// <exception cref="InvalidOperationException"></exception> - public void RemoveVirtualEndpoint(params string[] paths) + public void RemoveEndpoint(params string[] paths) { _ = paths ?? throw new ArgumentNullException(nameof(paths)); + //Make sure all endpoints specify a path if (paths.Any(static e => string.IsNullOrWhiteSpace(e))) { diff --git a/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs b/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs index 4fd77a6..4179f74 100644 --- a/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs +++ b/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -322,10 +322,10 @@ namespace VNLib.Plugins.Essentials.Extensions /// <exception cref="InvalidOperationException"></exception> /// <exception cref="ContentTypeUnacceptableException"></exception> [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, ContentType type, in ReadOnlySpan<char> data) + public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, ContentType type, ReadOnlySpan<char> data) { //Get a memory stream using UTF8 encoding - CloseResponse(ev, code, type, in data, ev.Server.Encoding); + CloseResponse(ev, code, type, data, ev.Server.Encoding); } /// <summary> @@ -338,7 +338,7 @@ namespace VNLib.Plugins.Essentials.Extensions /// <param name="encoding">The encoding type to use when converting the buffer</param> /// <remarks>This method will store an encoded copy as a memory stream, so be careful with large buffers</remarks> [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, ContentType type, in ReadOnlySpan<char> data, Encoding encoding) + public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, ContentType type, ReadOnlySpan<char> data, Encoding encoding) { if (data.IsEmpty) { @@ -479,7 +479,7 @@ namespace VNLib.Plugins.Essentials.Extensions try { //Deserialize and return the object - obj = value.AsJsonObject<T>(options); + obj = JsonSerializer.Deserialize<T>(value, options); return true; } catch(JsonException je) @@ -543,7 +543,7 @@ namespace VNLib.Plugins.Essentials.Extensions try { //Beware this will buffer the entire file object before it attmepts to de-serialize it - return VnEncoding.JSONDeserializeFromBinary<T>(file.FileData, options); + return JsonSerializer.Deserialize<T>(file.FileData, options); } catch (JsonException je) { diff --git a/lib/Plugins.Essentials/src/Extensions/HttpCookie.cs b/lib/Plugins.Essentials/src/Extensions/HttpCookie.cs index d7f73a3..332e3d6 100644 --- a/lib/Plugins.Essentials/src/Extensions/HttpCookie.cs +++ b/lib/Plugins.Essentials/src/Extensions/HttpCookie.cs @@ -37,11 +37,33 @@ namespace VNLib.Plugins.Essentials.Extensions /// <param name="Value">The cookie value</param> public readonly record struct HttpCookie (string Name, string Value) { + /// <summary> + /// The length of time the cookie is valid for + /// </summary> public readonly TimeSpan ValidFor { get; init; } = TimeSpan.MaxValue; - public readonly string Domain { get; init; } = ""; - public readonly string Path { get; init; } = "/"; + /// <summary> + /// The cookie's domain parameter. If null, is not set in the + /// Set-Cookie header. + /// </summary> + public readonly string? Domain { get; init; } = null; + /// <summary> + /// The cookies path parameter. If null, is not + /// set in the Set-Cookie header. + /// </summary> + public readonly string? Path { get; init; } = "/"; + /// <summary> + /// The cookie's same-site parameter. Default is <see cref="CookieSameSite.None"/> + /// </summary> public readonly CookieSameSite SameSite { get; init; } = CookieSameSite.None; + /// <summary> + /// Sets the cookie's HttpOnly parameter. Default is false. When false, does not + /// set the HttpOnly paramter in the Set-Cookie header. + /// </summary> public readonly bool HttpOnly { get; init; } = false; + /// <summary> + /// Sets the cookie's Secure parameter. Default is false. When false, does not + /// set the Secure parameter in the Set-Cookie header. + /// </summary> public readonly bool Secure { get; init; } = false; /// <summary> diff --git a/lib/Plugins.Essentials/src/HttpEntity.cs b/lib/Plugins.Essentials/src/HttpEntity.cs index 63e61f7..f2f9387 100644 --- a/lib/Plugins.Essentials/src/HttpEntity.cs +++ b/lib/Plugins.Essentials/src/HttpEntity.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -121,7 +121,7 @@ namespace VNLib.Plugins.Essentials /// <summary> /// The requested web root. Provides additional site information /// </summary> - public readonly EventProcessor RequestedRoot; + public readonly IWebProcessor RequestedRoot; /// <summary> /// If the request has query arguments they are stored in key value format /// </summary> diff --git a/lib/Plugins.Essentials/src/IEpProcessingOptions.cs b/lib/Plugins.Essentials/src/IEpProcessingOptions.cs index de79327..13dcd37 100644 --- a/lib/Plugins.Essentials/src/IEpProcessingOptions.cs +++ b/lib/Plugins.Essentials/src/IEpProcessingOptions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -27,6 +27,8 @@ using System.IO; using System.Net; using System.Collections.Generic; +using VNLib.Net.Http; + #nullable enable namespace VNLib.Plugins.Essentials @@ -62,5 +64,9 @@ namespace VNLib.Plugins.Essentials /// A <see cref="TimeSpan"/> for how long a connection may remain open before all operations are cancelled /// </summary> TimeSpan ExecutionTimeout { get; } + /// <summary> + /// HTTP level "hard" 301 redirects + /// </summary> + IReadOnlyDictionary<string, Redirect> HardRedirects { get; } } }
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/IWebProcessorInfo.cs b/lib/Plugins.Essentials/src/IWebProcessorInfo.cs new file mode 100644 index 0000000..93a9211 --- /dev/null +++ b/lib/Plugins.Essentials/src/IWebProcessorInfo.cs @@ -0,0 +1,82 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IWebProcessor.cs +* +* IWebProcessor.cs is part of VNLib.Plugins.Essentials which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +#nullable enable + +using VNLib.Net.Http; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Essentials +{ + /// <summary> + /// Abstractions for methods and information for processors + /// </summary> + public interface IWebProcessor : IWebRoot + { + /// <summary> + /// The filesystem entrypoint path for the site + /// </summary> + string Directory { get; } + + /// <summary> + /// Gets the EP processing options + /// </summary> + IEpProcessingOptions Options { get; } + + /// <summary> + /// The shared <see cref="IAccountSecurityProvider"/> that provides + /// user account security operations + /// </summary> + IAccountSecurityProvider AccountSecurity { get; } + + /// <summary> + /// <para> + /// Called when the server intends to process a file and requires translation from a + /// uri path to a usable filesystem path + /// </para> + /// <para> + /// NOTE: This function must be thread-safe! + /// </para> + /// </summary> + /// <param name="requestPath">The path requested by the request </param> + /// <returns>The translated and filtered filesystem path used to identify the file resource</returns> + string TranslateResourcePath(string requestPath); + + /// <summary> + /// Finds the file specified by the request and the server root the user has requested. + /// Determines if it exists, has permissions to access it, and allowed file attributes. + /// Also finds default files and files without extensions + /// </summary> + bool FindResourceInRoot(string resourcePath, bool fullyQualified, out string path); + + /// <summary> + /// Determines if a requested resource exists within the <see cref="EventProcessor"/> and is allowed to be accessed. + /// </summary> + /// <param name="resourcePath">The path to the resource</param> + /// <param name="path">An out parameter that is set to the absolute path to the existing and accessable resource</param> + /// <returns>True if the resource exists and is allowed to be accessed</returns> + bool FindResourceInRoot(string resourcePath, out string path); + } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Oauth/OauthHttpExtensions.cs b/lib/Plugins.Essentials/src/Oauth/OauthHttpExtensions.cs index 892a24c..11ab61a 100644 --- a/lib/Plugins.Essentials/src/Oauth/OauthHttpExtensions.cs +++ b/lib/Plugins.Essentials/src/Oauth/OauthHttpExtensions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -22,13 +22,12 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +using System; using System.Net; -using System.Text; using VNLib.Net.Http; -using VNLib.Utils; -using VNLib.Utils.IO; -using VNLib.Utils.Memory.Caching; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Extensions; namespace VNLib.Plugins.Essentials.Oauth @@ -74,11 +73,6 @@ namespace VNLib.Plugins.Essentials.Oauth public static class OauthHttpExtensions { - private static ThreadLocalObjectStorage<StringBuilder> SbRental { get; } = ObjectRental.CreateThreadLocal(Constructor, null, ReturnFunc); - - private static StringBuilder Constructor() => new(64); - private static void ReturnFunc(StringBuilder sb) => sb.Clear(); - /// <summary> /// Closes the current response with a json error message with the message details /// </summary> @@ -86,134 +80,53 @@ namespace VNLib.Plugins.Essentials.Oauth /// <param name="code">The http status code</param> /// <param name="error">The short error</param> /// <param name="description">The error description message</param> - public static void CloseResponseError(this HttpEntity ev, HttpStatusCode code, ErrorType error, string description) + public static void CloseResponseError(this IHttpEvent ev, HttpStatusCode code, ErrorType error, ReadOnlySpan<char> description) { //See if the response accepts json if (ev.Server.Accepts(ContentType.Json)) { - //Use a stringbuilder to create json result for the error description - StringBuilder sb = SbRental.Rent(); - sb.Append("{\"error\":\""); - switch (error) - { - case ErrorType.InvalidRequest: - sb.Append("invalid_request"); - break; - case ErrorType.InvalidClient: - sb.Append("invalid_client"); - break; - case ErrorType.UnauthorizedClient: - sb.Append("unauthorized_client"); - break; - case ErrorType.InvalidToken: - sb.Append("invalid_token"); - break; - case ErrorType.UnsupportedResponseType: - sb.Append("unsupported_response_type"); - break; - case ErrorType.InvalidScope: - sb.Append("invalid_scope"); - break; - case ErrorType.ServerError: - sb.Append("server_error"); - break; - case ErrorType.TemporarilyUnabavailable: - sb.Append("temporarily_unavailable"); - break; - default: - sb.Append("error"); - break; - } - sb.Append("\",\"error_description\":\""); - sb.Append(description); - sb.Append("\"}"); - //Close the response with the json data - ev.CloseResponse(code, ContentType.Json, sb.ToString()); - //Return the builder - SbRental.Return(sb); - } - //Otherwise set the error code in the wwwauth header - else - { - //Set the error result in the header - ev.Server.Headers[HttpResponseHeader.WwwAuthenticate] = error switch - { - ErrorType.InvalidRequest => $"Bearer error=\"invalid_request\"", - ErrorType.UnauthorizedClient => $"Bearer error=\"unauthorized_client\"", - ErrorType.UnsupportedResponseType => $"Bearer error=\"unsupported_response_type\"", - ErrorType.InvalidScope => $"Bearer error=\"invalid_scope\"", - ErrorType.ServerError => $"Bearer error=\"server_error\"", - ErrorType.TemporarilyUnabavailable => $"Bearer error=\"temporarily_unavailable\"", - ErrorType.InvalidClient => $"Bearer error=\"invalid_client\"", - ErrorType.InvalidToken => $"Bearer error=\"invalid_token\"", - _ => $"Bearer error=\"error\"", - }; - //Close the response with the status code - ev.CloseResponse(code); - } - } - /// <summary> - /// Closes the current response with a json error message with the message details - /// </summary> - /// <param name="ev"></param> - /// <param name="code">The http status code</param> - /// <param name="error">The short error</param> - /// <param name="description">The error description message</param> - public static void CloseResponseError(this IHttpEvent ev, HttpStatusCode code, ErrorType error, string description) - { - //See if the response accepts json - if (ev.Server.Accepts(ContentType.Json)) - { - //Use a stringbuilder to create json result for the error description - StringBuilder sb = SbRental.Rent(); - sb.Append("{\"error\":\""); + //Alloc char buffer to write output to, nearest page should give us enough room + using UnsafeMemoryHandle<char> buffer = MemoryUtil.UnsafeAllocNearestPage<char>(description.Length + 64); + ForwardOnlyWriter<char> writer = new(buffer.Span); + + //Build the error message string + writer.Append("{\"error\":\""); switch (error) { case ErrorType.InvalidRequest: - sb.Append("invalid_request"); + writer.Append("invalid_request"); break; case ErrorType.InvalidClient: - sb.Append("invalid_client"); + writer.Append("invalid_client"); break; case ErrorType.UnauthorizedClient: - sb.Append("unauthorized_client"); + writer.Append("unauthorized_client"); break; case ErrorType.InvalidToken: - sb.Append("invalid_token"); + writer.Append("invalid_token"); break; case ErrorType.UnsupportedResponseType: - sb.Append("unsupported_response_type"); + writer.Append("unsupported_response_type"); break; case ErrorType.InvalidScope: - sb.Append("invalid_scope"); + writer.Append("invalid_scope"); break; case ErrorType.ServerError: - sb.Append("server_error"); + writer.Append("server_error"); break; case ErrorType.TemporarilyUnabavailable: - sb.Append("temporarily_unavailable"); + writer.Append("temporarily_unavailable"); break; default: - sb.Append("error"); + writer.Append("error"); break; } - sb.Append("\",\"error_description\":\""); - sb.Append(description); - sb.Append("\"}"); + writer.Append("\",\"error_description\":\""); + writer.Append(description); + writer.Append("\"}"); - VnMemoryStream vms = VnEncoding.GetMemoryStream(sb.ToString(), ev.Server.Encoding); - try - { - //Close the response with the json data - ev.CloseResponse(code, ContentType.Json, vms); - } - catch - { - vms.Dispose(); - throw; - } - //Return the builder - SbRental.Return(sb); + //Close the response with the json data + ev.CloseResponse(code, ContentType.Json, writer.AsSpan()); } //Otherwise set the error code in the wwwauth header else diff --git a/lib/Plugins.Essentials/src/Sessions/SessionBase.cs b/lib/Plugins.Essentials/src/Sessions/SessionBase.cs index c8cab0d..46e6ec8 100644 --- a/lib/Plugins.Essentials/src/Sessions/SessionBase.cs +++ b/lib/Plugins.Essentials/src/Sessions/SessionBase.cs @@ -26,9 +26,7 @@ using System; using System.Net; using System.Runtime.CompilerServices; -using VNLib.Net.Http; using VNLib.Utils; -using VNLib.Utils.Async; namespace VNLib.Plugins.Essentials.Sessions { @@ -36,7 +34,7 @@ namespace VNLib.Plugins.Essentials.Sessions /// Provides a base class for the <see cref="ISession"/> interface for exclusive use within a multithreaded /// context /// </summary> - public abstract class SessionBase : AsyncExclusiveResource<IHttpEvent>, ISession + public abstract class SessionBase : ISession { protected const ulong MODIFIED_MSK = 0b0000000000000001UL; protected const ulong IS_NEW_MSK = 0b0000000000000010UL; @@ -68,24 +66,16 @@ namespace VNLib.Plugins.Essentials.Sessions } ///<inheritdoc/> - public virtual string SessionID { get; protected set; } + public abstract string SessionID { get; } ///<inheritdoc/> - public virtual DateTimeOffset Created { get; protected set; } + public abstract DateTimeOffset Created { get; set; } ///<inheritdoc/> ///<exception cref="ObjectDisposedException"></exception> public string this[string index] { - get - { - Check(); - return IndexerGet(index); - } - set - { - Check(); - IndexerSet(index, value); - } + get => IndexerGet(index); + set => IndexerSet(index, value); } ///<inheritdoc/> public virtual IPAddress UserIP diff --git a/lib/Plugins.Essentials/src/Sessions/SessionInfo.cs b/lib/Plugins.Essentials/src/Sessions/SessionInfo.cs index 816fa94..a5d7c4d 100644 --- a/lib/Plugins.Essentials/src/Sessions/SessionInfo.cs +++ b/lib/Plugins.Essentials/src/Sessions/SessionInfo.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -24,14 +24,15 @@ using System; using System.Net; +using System.Text.Json; using System.Security.Authentication; using System.Runtime.CompilerServices; using VNLib.Utils; using VNLib.Net.Http; -using VNLib.Utils.Extensions; using static VNLib.Plugins.Essentials.Statics; + /* * SessionInfo is a structure since it is only meant used in * an HttpEntity context, so it may be allocated as part of @@ -43,6 +44,8 @@ using static VNLib.Plugins.Essentials.Statics; #pragma warning disable CA1051 // Do not declare visible instance fields +#nullable enable + namespace VNLib.Plugins.Essentials.Sessions { /// <summary> @@ -55,13 +58,46 @@ namespace VNLib.Plugins.Essentials.Sessions /// </remarks> public readonly struct SessionInfo : IObjectStorage, IEquatable<SessionInfo> { + /* + * Store status flags as a 1 byte enum + */ + [Flags] + private enum SessionFlags : byte + { + None = 0x00, + IsSet = 0x01, + IpMatch = 0x02, + IsCrossOrigin = 0x04, + CrossOriginMatch = 0x08, + } + + private readonly ISession UserSession; + private readonly SessionFlags _flags; + /// <summary> /// A value indicating if the current instance has been initiailzed /// with a session. Otherwise properties are undefied /// </summary> - public readonly bool IsSet; + public readonly bool IsSet + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _flags.HasFlag(SessionFlags.IsSet); + } - private readonly ISession UserSession; + /// <summary> + /// The origin header specified during session creation + /// </summary> + public readonly Uri? SpecifiedOrigin; + + /// <summary> + /// Was the session Initialy established on a secure connection? + /// </summary> + public readonly SslProtocols SecurityProcol; + + /// <summary> + /// Session stored User-Agent + /// </summary> + public readonly string? UserAgent; /// <summary> /// Key that identifies the current session. (Identical to cookie::sessionid) @@ -71,51 +107,52 @@ namespace VNLib.Plugins.Essentials.Sessions [MethodImpl(MethodImplOptions.AggressiveInlining)] get => UserSession.SessionID; } - /// <summary> - /// Session stored User-Agent - /// </summary> - public readonly string UserAgent; + /// <summary> /// If the stored IP and current user's IP matches /// </summary> - public readonly bool IPMatch; + public readonly bool IPMatch + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _flags.HasFlag(SessionFlags.IpMatch); + } + /// <summary> /// If the current connection and stored session have matching cross origin domains /// </summary> - public readonly bool CrossOriginMatch; - /// <summary> - /// Flags the session as invalid. IMPORTANT: the user's session data is no longer valid and will throw an exception when accessed - /// </summary> - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Invalidate(bool all = false) => UserSession.Invalidate(all); - /// <summary> - /// Marks the session ID to be regenerated during closing event - /// </summary> - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void RegenID() => UserSession.RegenID(); - ///<inheritdoc/> - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public T GetObject<T>(string key) => this[key].AsJsonObject<T>(SR_OPTIONS); - ///<inheritdoc/> - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SetObject<T>(string key, T obj) => this[key] = obj?.ToJsonString(SR_OPTIONS); + public readonly bool CrossOriginMatch + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _flags.HasFlag(SessionFlags.CrossOriginMatch); + } /// <summary> /// Was the original session cross origin? /// </summary> - public readonly bool CrossOrigin; + public readonly bool CrossOrigin + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _flags.HasFlag(SessionFlags.IsCrossOrigin); + } + /// <summary> - /// The origin header specified during session creation + /// Was this session just created on this connection? /// </summary> - public readonly Uri SpecifiedOrigin; + public readonly bool IsNew + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => UserSession.IsNew; + } + /// <summary> /// The time the session was created /// </summary> - public readonly DateTimeOffset Created; - /// <summary> - /// Was this session just created on this connection? - /// </summary> - public readonly bool IsNew; + public readonly DateTimeOffset Created + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => UserSession.Created; + } + /// <summary> /// Gets or sets the session's login hash, if set to a non-empty/null value, will trigger an upgrade on close /// </summary> @@ -126,6 +163,7 @@ namespace VNLib.Plugins.Essentials.Sessions [MethodImpl(MethodImplOptions.AggressiveInlining)] set => UserSession.SetLoginToken(value); } + /// <summary> /// Gets or sets the session's login token, if set to a non-empty/null value, will trigger an upgrade on close /// </summary> @@ -136,6 +174,7 @@ namespace VNLib.Plugins.Essentials.Sessions [MethodImpl(MethodImplOptions.AggressiveInlining)] set => UserSession.Token = value; } + /// <summary> /// <para> /// Gets or sets the user-id for the current session. @@ -151,6 +190,7 @@ namespace VNLib.Plugins.Essentials.Sessions [MethodImpl(MethodImplOptions.AggressiveInlining)] set => UserSession.UserID = value; } + /// <summary> /// Privilages associated with user specified during login /// </summary> @@ -161,6 +201,7 @@ namespace VNLib.Plugins.Essentials.Sessions [MethodImpl(MethodImplOptions.AggressiveInlining)] set => UserSession.Privilages = value; } + /// <summary> /// The IP address belonging to the client /// </summary> @@ -168,11 +209,8 @@ namespace VNLib.Plugins.Essentials.Sessions { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => UserSession.UserIP; - } - /// <summary> - /// Was the session Initialy established on a secure connection? - /// </summary> - public readonly SslProtocols SecurityProcol; + } + /// <summary> /// A value specifying the type of the backing session /// </summary> @@ -183,6 +221,27 @@ namespace VNLib.Plugins.Essentials.Sessions } /// <summary> + /// Flags the session as invalid. IMPORTANT: the user's session data is no longer valid, no data + /// will be saved to the session store when the session closes + /// </summary> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Invalidate(bool all = false) => UserSession.Invalidate(all); + /// <summary> + /// Marks the session ID to be regenerated during closing event + /// </summary> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void RegenID() => UserSession.RegenID(); + +#nullable disable + ///<inheritdoc/> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public T GetObject<T>(string key) => JsonSerializer.Deserialize<T>(this[key], SR_OPTIONS); + ///<inheritdoc/> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetObject<T>(string key, T obj) => this[key] = obj == null ? null: JsonSerializer.Serialize(obj, SR_OPTIONS); +#nullable enable + + /// <summary> /// Accesses the session's general storage /// </summary> /// <param name="index">Key for specifie data</param> @@ -198,37 +257,45 @@ namespace VNLib.Plugins.Essentials.Sessions internal SessionInfo(ISession session, IConnectionInfo ci, IPAddress trueIp) { UserSession = session; - //Calculate and store - IsNew = session.IsNew; - Created = session.Created; - //Ip match - IPMatch = trueIp.Equals(session.UserIP); + + SessionFlags flags = SessionFlags.IsSet; + + //Set ip match flag if current ip and stored ip match + flags |= trueIp.Equals(session.UserIP) ? SessionFlags.IpMatch : SessionFlags.None; + //If the session is new, we can store intial security variables if (session.IsNew) { session.InitNewSession(ci); + //Since all values will be the same as the connection, cache the connection values UserAgent = ci.UserAgent; SpecifiedOrigin = ci.Origin; - CrossOrigin = ci.CrossOrigin; SecurityProcol = ci.SecurityProtocol; + + flags |= ci.CrossOrigin ? SessionFlags.IsCrossOrigin : SessionFlags.None; } else { //Load/decode stored variables UserAgent = session.GetUserAgent(); SpecifiedOrigin = session.GetOriginUri(); - CrossOrigin = session.IsCrossOrigin(); SecurityProcol = session.GetSecurityProtocol(); + + flags |= session.IsCrossOrigin() ? SessionFlags.IsCrossOrigin : SessionFlags.None; } - CrossOriginMatch = ci.Origin != null && ci.Origin.Equals(SpecifiedOrigin); - IsSet = true; + + //Set cross origin orign match flags, if the stored origin, and connection origin + flags |= ci.Origin != null && ci.Origin.Equals(SpecifiedOrigin) ? SessionFlags.CrossOriginMatch : SessionFlags.None; + + //store flags + _flags = flags; } ///<inheritdoc/> public bool Equals(SessionInfo other) => SessionID.Equals(other.SessionID, StringComparison.Ordinal); ///<inheritdoc/> - public override bool Equals(object obj) => obj is SessionInfo si && Equals(si); + public override bool Equals(object? obj) => obj is SessionInfo si && Equals(si); ///<inheritdoc/> public override int GetHashCode() => SessionID.GetHashCode(StringComparison.Ordinal); ///<inheritdoc/> diff --git a/lib/Plugins.Runtime/src/IPluginEventListener.cs b/lib/Plugins.Runtime/src/IPluginEventListener.cs new file mode 100644 index 0000000..5d97343 --- /dev/null +++ b/lib/Plugins.Runtime/src/IPluginEventListener.cs @@ -0,0 +1,49 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Runtime +* File: IPluginEventListener.cs +* +* IPluginEventListener.cs is part of VNLib.Plugins.Runtime which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Runtime is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Runtime is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Runtime. If not, see http://www.gnu.org/licenses/. +*/ + +namespace VNLib.Plugins.Runtime +{ + /// <summary> + /// Represents a plugin event consumer. + /// </summary> + public interface IPluginEventListener + { + /// <summary> + /// Called by the registered <see cref="PluginController"/> + /// to notify this listener that the plugins within the collection + /// have been initialized and loaded + /// </summary> + /// <param name="controller">The collection on which the load event occured</param> + /// <param name="state">The registration state parameter</param> + void OnPluginLoaded(PluginController controller, object? state); + /// <summary> + /// Called by the registered <see cref="PluginController"/> + /// to notify this listener that this plugins within the + /// collection have been unloaded + /// </summary> + /// <param name="controller">The controller that is reloading</param> + /// <param name="state">The registration state parameter</param> + void OnPluginUnloaded(PluginController controller, object? state); + } +} diff --git a/lib/Plugins.Runtime/src/IPluginEventRegistrar.cs b/lib/Plugins.Runtime/src/IPluginEventRegistrar.cs new file mode 100644 index 0000000..120f437 --- /dev/null +++ b/lib/Plugins.Runtime/src/IPluginEventRegistrar.cs @@ -0,0 +1,47 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Runtime +* File: IPluginEventRegistrar.cs +* +* IPluginEventRegistrar.cs is part of VNLib.Plugins.Runtime which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Runtime is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Runtime is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Runtime. If not, see http://www.gnu.org/licenses/. +*/ + +namespace VNLib.Plugins.Runtime +{ + /// <summary> + /// Represents a type that accepts <see cref="IPluginEventListener"/> + /// event handlers and allow them to unload events + /// </summary> + public interface IPluginEventRegistrar + { + /// <summary> + /// Registers a plugin event listener + /// </summary> + /// <param name="listener">The event handler instance to register</param> + /// <param name="state">An optional state paremeter to pass to the event handler</param> + void Register(IPluginEventListener listener, object? state = null); + + /// <summary> + /// Unregisters the event listener + /// </summary> + /// <param name="listener">The event handler instance to unregister</param> + /// <returns>A value that indicates if the event handler was successfully unregistered</returns> + bool Unregister(IPluginEventListener listener); + } +} diff --git a/lib/Plugins.Runtime/src/ITypedPluginConsumer.cs b/lib/Plugins.Runtime/src/ITypedPluginConsumer.cs new file mode 100644 index 0000000..9c8a477 --- /dev/null +++ b/lib/Plugins.Runtime/src/ITypedPluginConsumer.cs @@ -0,0 +1,47 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Runtime +* File: ITypedPluginConsumer.cs +* +* ITypedPluginConsumer.cs is part of VNLib.Plugins.Runtime which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Runtime is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Runtime is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Runtime. If not, see http://www.gnu.org/licenses/. +*/ + +namespace VNLib.Plugins.Runtime +{ + /// <summary> + /// An abstraction that represents a consumer of a dynamically loaded type. + /// </summary> + /// <typeparam name="T">The service type to consume</typeparam> + public interface ITypedPluginConsumer<T> + { + /// <summary> + /// Invoked when the instance of the desired type is loaded. + /// This is a new instance of the desired type + /// </summary> + /// <param name="plugin">A new instance of the requested type</param> + void OnLoad(T plugin, object? state); + + /// <summary> + /// Called when the loader that maintains the instance is unloading + /// the type. + /// </summary> + /// <param name="plugin">The instance of the type that is being unloaded</param> + void OnUnload(T plugin, object? state); + } +} diff --git a/lib/Plugins.Runtime/src/LivePlugin.cs b/lib/Plugins.Runtime/src/LivePlugin.cs index c0011dd..ae4c90b 100644 --- a/lib/Plugins.Runtime/src/LivePlugin.cs +++ b/lib/Plugins.Runtime/src/LivePlugin.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Runtime @@ -27,12 +27,12 @@ using System.Linq; using System.Reflection; using System.Text.Json; -using VNLib.Utils.Logging; using VNLib.Utils.Extensions; using VNLib.Plugins.Attributes; namespace VNLib.Plugins.Runtime { + /// <summary> /// <para> /// Wrapper for a loaded <see cref="IPlugin"/> instance, used internally @@ -46,6 +46,8 @@ namespace VNLib.Plugins.Runtime /// </summary> public class LivePlugin : IEquatable<IPlugin>, IEquatable<LivePlugin> { + private bool _loaded; + /// <summary> /// The plugin's <see cref="IPlugin.PluginName"/> property during load time /// </summary> @@ -57,14 +59,24 @@ namespace VNLib.Plugins.Runtime /// by he current instance /// </summary> public IPlugin? Plugin { get; private set; } + + /// <summary> + /// The assembly that this plugin was created from + /// </summary> + public Assembly OriginAsm { get; } - private readonly Type PluginType; + /// <summary> + /// The exposed runtime type of the plugin. Equivalent to + /// calling <code>Plugin.GetType()</code> + /// </summary> + public Type PluginType { get; } private ConsoleEventHandlerSignature? PluginConsoleHandler; - internal LivePlugin(IPlugin plugin) + internal LivePlugin(IPlugin plugin, Assembly originAsm) { Plugin = plugin; + OriginAsm = originAsm; PluginType = plugin.GetType(); GetConsoleHandler(); } @@ -153,42 +165,38 @@ namespace VNLib.Plugins.Runtime /// <summary> /// Calls the <see cref="IPlugin.Load"/> method on the plugin if its loaded /// </summary> - internal void LoadPlugin() => Plugin?.Load(); + internal void LoadPlugin() + { + //Load and set loaded flag + Plugin?.Load(); + _loaded = true; + } /// <summary> - /// Unloads all loaded endpoints from - /// that they were loaded to, then unloads the plugin. + /// Unloads the plugin, only if the plugin was successfully loaded by + /// calling the <see cref="IPlugin.Unload"/> event hook. /// </summary> - /// <param name="logSink">An optional log provider to write unload exceptions to</param> - /// <remarks> - /// If <paramref name="logSink"/> is no null unload exceptions are swallowed and written to the log - /// </remarks> - internal void UnloadPlugin(ILogProvider? logSink) + internal void UnloadPlugin() { - /* - * We need to swallow plugin unload errors to avoid - * unknown state, making sure endpoints are properly - * unloaded! - */ + //Remove delegate handler to the plugin to remove refs + PluginConsoleHandler = null; + + //Only call unload if the plugin successfully loaded + if (!_loaded) + { + return; + } + try { - //Unload the plugin Plugin?.Unload(); } - catch (Exception ex) + finally { - //Create an unload wrapper for the exception - PluginUnloadException wrapper = new("Exception raised during plugin unload", ex); - if (logSink == null) - { - throw wrapper; - } - //Write error to log sink - logSink.Error(wrapper); + Plugin = null; } - Plugin = null; - PluginConsoleHandler = null; } + ///<inheritdoc/> public override bool Equals(object? obj) { diff --git a/lib/Plugins.Runtime/src/LoaderExtensions.cs b/lib/Plugins.Runtime/src/LoaderExtensions.cs index 795dcf5..13fbb11 100644 --- a/lib/Plugins.Runtime/src/LoaderExtensions.cs +++ b/lib/Plugins.Runtime/src/LoaderExtensions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Runtime @@ -23,98 +23,148 @@ */ using System; -using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + namespace VNLib.Plugins.Runtime { + /// <summary> + /// Contains extension methods for PluginLoader library + /// </summary> public static class LoaderExtensions { - /// <summary> - /// Searches all plugins within the current loader for a - /// single plugin that derrives the specified type - /// </summary> - /// <typeparam name="T">The type the plugin must derrive from</typeparam> - /// <param name="loader"></param> - /// <returns>The instance of the plugin that derrives from the specified type</returns> - public static LivePlugin? GetExposedPlugin<T>(this RuntimePluginLoader loader) + /* + * Class that manages a collection registration for a specific type + * dependency, and redirects the event calls for the consumed service + */ + private sealed class TypedRegistration<T> : IPluginEventListener where T: class { - return loader.LivePlugins - .Where(static pl => typeof(T).IsAssignableFrom(pl.Plugin!.GetType())) - .SingleOrDefault(); + private readonly ITypedPluginConsumer<T> _consumerEvents; + private readonly object? _userState; + + private T? _service; + private readonly Type _type; + + public TypedRegistration(ITypedPluginConsumer<T> consumerEvents, Type type) + { + _consumerEvents = consumerEvents; + _type = type; + } + + + public void OnPluginLoaded(PluginController controller, object? state) + { + //Get the service from the loaded plugins + T service = controller.Plugins + .Where(pl => _type.IsAssignableFrom(pl.PluginType)) + .Select(static pl => (T)pl.Plugin!) + .First(); + + //Call load with the exported type + _consumerEvents.OnLoad(service, _userState); + + //Store for unload + _service = service; + } + + public void OnPluginUnloaded(PluginController controller, object? state) + { + //Unload + _consumerEvents.OnUnload(_service!, _userState); + _service = null; + } } /// <summary> - /// Searches all plugins within the current loader for a - /// single plugin that derrives the specified type + /// Registers a plugin even handler for the current <see cref="PluginController"/> + /// for a specific type. /// </summary> - /// <typeparam name="T">The type the plugin must derrive from</typeparam> - /// <param name="loader"></param> - /// <returns>The instance of your custom type casted, or null if not found or could not be casted</returns> - public static T? GetExposedTypeFromPlugin<T>(this RuntimePluginLoader loader) where T: class + /// <typeparam name="T"></typeparam> + /// <param name="collection"></param> + /// <param name="consumer">The typed plugin instance event consumer</param> + /// <returns>A <see cref="PluginEventRegistration"/> handle that manages this event registration</returns> + /// <exception cref="ArgumentException"></exception> + public static PluginEventRegistration RegisterForType<T>(this PluginController collection, ITypedPluginConsumer<T> consumer) where T: class { - LivePlugin? plugin = loader.LivePlugins - .Where(static pl => typeof(T).IsAssignableFrom(pl.Plugin!.GetType())) - .SingleOrDefault(); + Type serviceType = typeof(T); - return plugin?.Plugin as T; + //Confim the type is exposed by this collection + if(!ExposesType(collection, serviceType)) + { + throw new ArgumentException("The requested type is not exposed in this assembly"); + } + + //Create new typed listener + TypedRegistration<T> reg = new(consumer, serviceType); + + //register event handler + return Register(collection, reg, null); } /// <summary> - /// Registers a listener delegate method to invoke when the - /// current <see cref="RuntimePluginLoader"/> is reloaded, and passes - /// the new instance of the specified type + /// Registers a handler to listen for plugin load/unload events /// </summary> - /// <typeparam name="T">The single plugin type to register a listener for</typeparam> - /// <param name="loader"></param> - /// <param name="reloaded">The delegate method to invoke when the loader has reloaded plugins</param> /// <exception cref="ArgumentNullException"></exception> - public static bool RegisterListenerForSingle<T>(this RuntimePluginLoader loader, Action<T, T> reloaded) where T: class + /// <returns>A <see cref="PluginEventRegistration"/> handle that will unregister the listener when disposed</returns> + public static PluginEventRegistration Register(this IPluginEventRegistrar reg, IPluginEventListener listener, object? state = null) { - _ = reloaded ?? throw new ArgumentNullException(nameof(reloaded)); + reg.Register(listener, state); + return new(reg, listener); + } - //try to get the casted type from the loader - T? current = loader.GetExposedTypeFromPlugin<T>(); + /// <summary> + /// Loads the configuration file into its <see cref="JsonDocument"/> format + /// for reading. + /// </summary> + /// <param name="loader"></param> + /// <returns>A new <see cref="JsonDocument"/> of the loaded configuration file</returns> + public static async Task<JsonDocument> GetPluginConfigAsync(this RuntimePluginLoader loader) + { + //Open and read the config file + await using FileStream confStream = File.OpenRead(loader.PluginConfigPath); - if (current == null) - { - return false; - } - else + JsonDocumentOptions jdo = new() { - loader.Reloaded += delegate (object? sender, EventArgs args) - { - RuntimePluginLoader wpl = (sender as RuntimePluginLoader)!; - //Get the new loaded type - T newT = (wpl.GetExposedPlugin<T>()!.Plugin as T)!; - //Invoke reloaded action - reloaded(current, newT); - //update the new current instance - current = newT; - }; - - return true; - } + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + //parse the plugin config file + return await JsonDocument.ParseAsync(confStream, jdo); } /// <summary> - /// Gets all endpoints exposed by all exported plugin instances - /// within the current loader + /// Determines if the current <see cref="PluginController"/> + /// exposes the desired type on is <see cref="IPlugin"/> + /// type. /// </summary> - /// <param name="loader"></param> - /// <returns>An enumeration of all endpoints</returns> - public static IEnumerable<IEndpoint> GetEndpoints(this RuntimePluginLoader loader) => loader.LivePlugins.SelectMany(static pl => pl.Plugin!.GetEndpoints()); + /// <param name="collection"></param> + /// <param name="type">The desired type to request</param> + /// <returns>True if the plugin exposes the desired type, false otherwise</returns> + public static bool ExposesType(this PluginController collection, Type type) + { + return collection.Plugins + .Where(pl => type.IsAssignableFrom(pl.PluginType)) + .Any(); + } /// <summary> - /// Determines if any loaded plugin types exposes an instance of the - /// specified type + /// Searches all plugins within the current loader for a + /// single plugin that derrives the specified type /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type the plugin must derrive from</typeparam> /// <param name="loader"></param> - /// <returns>True if any plugin instance exposes a the specified type, false otherwise</returns> - public static bool ExposesType<T>(this RuntimePluginLoader loader) where T : class + /// <returns>The instance of your custom type casted, or null if not found or could not be casted</returns> + public static T? GetExposedTypes<T>(this PluginController collection) where T: class { - return loader.LivePlugins.Any(static pl => typeof(T).IsAssignableFrom(pl.Plugin?.GetType())); + LivePlugin? plugin = collection.Plugins + .Where(static pl => typeof(T).IsAssignableFrom(pl.PluginType)) + .SingleOrDefault(); + + return plugin?.Plugin as T; } } } diff --git a/lib/Plugins.Runtime/src/PluginController.cs b/lib/Plugins.Runtime/src/PluginController.cs new file mode 100644 index 0000000..14ea7f0 --- /dev/null +++ b/lib/Plugins.Runtime/src/PluginController.cs @@ -0,0 +1,127 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Runtime +* File: PluginController.cs +* +* PluginController.cs is part of VNLib.Plugins.Runtime which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Runtime is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Runtime is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Runtime. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Text.Json; +using System.Reflection; +using System.Collections.Generic; + +using VNLib.Utils.Extensions; + +namespace VNLib.Plugins.Runtime +{ + /// <summary> + /// Manages the lifetime of a collection of <see cref="IPlugin"/> instances, + /// and their dependent event listeners + /// </summary> + public sealed class PluginController : IPluginEventRegistrar + { + private readonly List<LivePlugin> _plugins; + private readonly List<KeyValuePair<IPluginEventListener, object?>> _listeners; + + internal PluginController() + { + _plugins = new (); + _listeners = new (); + } + + /// <summary> + /// The current collection of plugins. Valid before the unload event. + /// </summary> + public IEnumerable<LivePlugin> Plugins => _plugins; + + ///<inheritdoc/> + ///<exception cref="ArgumentNullException"></exception> + public void Register(IPluginEventListener listener, object? state = null) + { + _ = listener ?? throw new ArgumentNullException(nameof(listener)); + + _listeners.Add(new(listener, state)); + } + + ///<inheritdoc/> + public bool Unregister(IPluginEventListener listener) + { + //Remove listener + return _listeners.RemoveAll(p => p.Key == listener) > 0; + } + + + internal void InitializePlugins(Assembly asm) + { + //get all Iplugin types + Type[] types = asm.GetTypes().Where(static type => !type.IsAbstract && typeof(IPlugin).IsAssignableFrom(type)).ToArray(); + + //Initialize the new plugin instances + IPlugin[] plugins = types.Select(static t => (IPlugin)Activator.CreateInstance(t)!).ToArray(); + + //Crate new containers + LivePlugin[] lps = plugins.Select(p => new LivePlugin(p, asm)).ToArray(); + + //Store containers + _plugins.AddRange(lps); + } + + internal void ConfigurePlugins(JsonDocument hostDom, JsonDocument pluginDom, string[] cliArgs) + { + _plugins.TryForeach(lp => lp.InitConfig(hostDom, pluginDom)); + _plugins.TryForeach(lp => lp.InitLog(cliArgs)); + } + + internal void LoadPlugins() + { + //Load all plugins + _plugins.TryForeach(static p => p.LoadPlugin()); + + //Notify event handlers + _listeners.TryForeach(l => l.Key.OnPluginLoaded(this, l.Value)); + } + + internal void UnloadPlugins() + { + try + { + //Notify event handlers + _listeners.TryForeach(l => l.Key.OnPluginUnloaded(this, l.Value)); + + //Unload plugin instances + _plugins.TryForeach(static p => p.UnloadPlugin()); + } + finally + { + //Always + _plugins.Clear(); + } + } + + internal void Dispose() + { + _plugins.Clear(); + _listeners.Clear(); + } + + + } +} diff --git a/lib/Plugins.Runtime/src/PluginEventRegistration.cs b/lib/Plugins.Runtime/src/PluginEventRegistration.cs new file mode 100644 index 0000000..37d6162 --- /dev/null +++ b/lib/Plugins.Runtime/src/PluginEventRegistration.cs @@ -0,0 +1,69 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Runtime +* File: PluginEventRegistration.cs +* +* PluginEventRegistration.cs is part of VNLib.Plugins.Runtime which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Runtime is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Runtime is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Runtime. If not, see http://www.gnu.org/licenses/. +*/ + +using System; + +namespace VNLib.Plugins.Runtime +{ + /// <summary> + /// Holds a registration for events to a given <see cref="IPluginEventRegistrar"/>. + /// The event listener is unregistered from events when this registration is disposed. + /// </summary> + public readonly record struct PluginEventRegistration : IDisposable + { + private readonly IPluginEventRegistrar _registrar; + private readonly IPluginEventListener _listener; + + internal PluginEventRegistration(IPluginEventRegistrar container, IPluginEventListener listener) + { + _listener = listener; + _registrar = container; + } + + /// <summary> + /// Unreigsers the listner and releases held resources + /// </summary> + public readonly void Dispose() + { + _ = _registrar?.Unregister(_listener); + } + + /// <summary> + /// Unregisters a previously registered <see cref="IPluginEventListener"/> + /// from the <see cref="PluginController"/> it was registered to + /// </summary> + /// <exception cref="InvalidOperationException"></exception> + public readonly void Unregister() + { + if (_registrar == null) + { + return; + } + if (!_registrar.Unregister(_listener)) + { + throw new InvalidOperationException("The listner has already been unregistered"); + } + } + } +} diff --git a/lib/Plugins.Runtime/src/RuntimePluginLoader.cs b/lib/Plugins.Runtime/src/RuntimePluginLoader.cs index c688f8b..d79def3 100644 --- a/lib/Plugins.Runtime/src/RuntimePluginLoader.cs +++ b/lib/Plugins.Runtime/src/RuntimePluginLoader.cs @@ -1,11 +1,11 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Runtime -* File: DynamicPluginLoader.cs +* File: RuntimePluginLoader.cs * -* DynamicPluginLoader.cs is part of VNLib.Plugins.Runtime which is part of the larger +* RuntimePluginLoader.cs is part of VNLib.Plugins.Runtime which is part of the larger * VNLib collection of libraries and utilities. * * VNLib.Plugins.Runtime is free software: you can redistribute it and/or modify @@ -24,20 +24,16 @@ using System; using System.IO; -using System.Linq; using System.Text.Json; using System.Reflection; using System.Runtime.Loader; using System.Threading.Tasks; -using System.Collections.Generic; using McMaster.NETCore.Plugins; using VNLib.Utils; using VNLib.Utils.IO; using VNLib.Utils.Logging; -using VNLib.Utils.Extensions; - namespace VNLib.Plugins.Runtime { @@ -45,50 +41,23 @@ namespace VNLib.Plugins.Runtime /// A runtime .NET assembly loader specialized to load /// assemblies that export <see cref="IPlugin"/> types. /// </summary> - public class RuntimePluginLoader : VnDisposeable + public sealed class RuntimePluginLoader : VnDisposeable { - protected readonly PluginLoader Loader; - protected readonly string PluginPath; - protected readonly JsonDocument HostConfig; - protected readonly ILogProvider? Log; - protected readonly LinkedList<LivePlugin> LoadedPlugins; - - /// <summary> - /// A readonly collection of all loaded plugin wrappers - /// </summary> - public IReadOnlyCollection<LivePlugin> LivePlugins => LoadedPlugins; - - /// <summary> - /// An event that is raised before the loader - /// unloads all plugin instances - /// </summary> - protected event EventHandler<PluginReloadedEventArgs>? OnBeforeReloaded; - /// <summary> - /// An event that is raised after a successfull reload of all new - /// plugins for the instance - /// </summary> - protected event EventHandler? OnAfterReloaded; - - /// <summary> - /// Raised when the current loader has reloaded the assembly and - /// all plugins were successfully loaded. - /// </summary> - public event EventHandler? Reloaded; + private readonly PluginLoader Loader; + private readonly string PluginPath; + private readonly JsonDocument HostConfig; + private readonly ILogProvider? Log; /// <summary> - /// The current plugin's JSON configuration DOM loaded from the plugin's directory - /// if it exists. Only valid after first initalization + /// Gets the plugin lifetime manager. /// </summary> - public JsonDocument? PluginConfigDOM { get; private set; } - /// <summary> - /// Optional loader arguments object for the plugin - /// </summary> - protected JsonElement? LoaderArgs { get; private set; } + public PluginController Controller { get; } /// <summary> /// The path of the plugin's configuration file. (Default = pluginPath.json) /// </summary> - public string PluginConfigPath { get; init; } + public string PluginConfigPath { get; } + /// <summary> /// Creates a new <see cref="RuntimePluginLoader"/> with the specified /// assembly location and host config. @@ -98,18 +67,16 @@ namespace VNLib.Plugins.Runtime /// <param name="hostConfig">The configuration DOM to merge with plugin config DOM and pass to enabled plugins</param> /// <param name="unloadable">A value that specifies if the assembly can be unloaded</param> /// <param name="hotReload">A value that spcifies if the loader will listen for changes to the assembly file and reload the plugins</param> - /// <param name="lazy">A value that specifies if assembly dependencies are loaded on-demand</param> /// <remarks> /// The <paramref name="log"/> argument may be null if <paramref name="unloadable"/> is false /// </remarks> /// <exception cref="ArgumentNullException"></exception> - public RuntimePluginLoader(string pluginPath, JsonDocument? hostConfig = null, ILogProvider? log = null, bool unloadable = false, bool hotReload = false, bool lazy = false) + public RuntimePluginLoader(string pluginPath, JsonDocument? hostConfig = null, ILogProvider? log = null, bool unloadable = false, bool hotReload = false) :this( new PluginConfig(pluginPath) { IsUnloadable = unloadable || hotReload, EnableHotReload = hotReload, - IsLazyLoaded = lazy, ReloadDelay = TimeSpan.FromSeconds(1), PreferSharedTypes = true, DefaultContext = AssemblyLoadContext.Default @@ -117,6 +84,7 @@ namespace VNLib.Plugins.Runtime hostConfig, log) { } + /// <summary> /// Creates a new <see cref="RuntimePluginLoader"/> with the specified config and host config dom. /// </summary> @@ -126,124 +94,119 @@ namespace VNLib.Plugins.Runtime /// <exception cref="ArgumentNullException"></exception> public RuntimePluginLoader(PluginConfig config, JsonDocument? hostConfig, ILogProvider? log) { - //Add the assembly from which the IPlugin library was loaded from - config.SharedAssemblies.Add(typeof(IPlugin).Assembly.GetName()); - + //Shared types is required so the default load context shares types + config.PreferSharedTypes = true; + //Default to empty config if null HostConfig = hostConfig ?? JsonDocument.Parse("{}"); + Loader = new(config); PluginPath = config.MainAssemblyPath; Log = log; - Loader.Reloaded += Loader_Reloaded; + + //Only regiser reload handler if the load context is unloadable + if (config.IsUnloadable) + { + //Init reloaded event handler + Loader.Reloaded += Loader_Reloaded; + } + //Set the config path default PluginConfigPath = Path.ChangeExtension(PluginPath, ".json"); - LoadedPlugins = new(); + + //Init container + Controller = new(); } private async void Loader_Reloaded(object sender, PluginReloadedEventArgs eventArgs) { try { - //Invoke reloaded events - OnBeforeReloaded?.Invoke(this, eventArgs); - //Unload all endpoints - LoadedPlugins.TryForeach(lp => lp.UnloadPlugin(Log)); - //Clear list of loaded plugins - LoadedPlugins.Clear(); - //Unload the plugin config - PluginConfigDOM?.Dispose(); + //All plugins must be unloaded forst + UnloadAll(); + //Reload the assembly and - await InitLoaderAsync(); - //fire after loaded - OnAfterReloaded?.Invoke(this, eventArgs); - //Raise the external reloaded event - Reloaded?.Invoke(this, EventArgs.Empty); + await InitializeController(); + + //Load plugins + LoadPlugins(); } catch (Exception ex) { - Log?.Error(ex); + Log?.Error("Failed reload plugins for {loader}\n{ex}", PluginPath, ex); } } /// <summary> - /// Initializes the plugin loader, the assembly, and all public <see cref="IPlugin"/> - /// types + /// Initializes the plugin loader, and populates the <see cref="Controller"/> + /// with initialized plugins. /// </summary> /// <returns>A task that represents the initialization</returns> - public async Task InitLoaderAsync() + /// <exception cref="IOException"></exception> + /// <exception cref="FileNotFoundException"></exception> + public async Task InitializeController() { - //Load the main assembly - Assembly PluginAsm = Loader.LoadDefaultAssembly(); - //Get the plugin's configuration file - if (FileOperations.FileExists(PluginConfigPath)) + JsonDocument? pluginConfig = null; + + try { - //Open and read the config file - await using FileStream confStream = File.OpenRead(PluginConfigPath); - JsonDocumentOptions jdo = new() + //Get the plugin's configuration file + if (FileOperations.FileExists(PluginConfigPath)) { - AllowTrailingCommas = true, - CommentHandling = JsonCommentHandling.Skip, - }; - //parse the plugin config file - PluginConfigDOM = await JsonDocument.ParseAsync(confStream, jdo); - //Store the config loader args - if (PluginConfigDOM.RootElement.TryGetProperty("loader_args", out JsonElement loaderEl)) + pluginConfig = await this.GetPluginConfigAsync(); + } + else { - LoaderArgs = loaderEl; + //Set plugin config dom to an empty object if the file does not exist + pluginConfig = JsonDocument.Parse("{}"); } + + //Load the main assembly + Assembly PluginAsm = Loader.LoadDefaultAssembly(); + + //Init container from the assembly + Controller.InitializePlugins(PluginAsm); + + string[] cliArgs = Environment.GetCommandLineArgs(); + + //Configure log/doms + Controller.ConfigurePlugins(HostConfig, pluginConfig, cliArgs); } - else - { - //Set plugin config dom to an empty object if the file does not exist - PluginConfigDOM = JsonDocument.Parse("{}"); - LoaderArgs = null; - } - - string[] cliArgs = Environment.GetCommandLineArgs(); - - //Get all types that implement the IPlugin interface - IEnumerable<IPlugin> plugins = PluginAsm.GetTypes().Where(static type => !type.IsAbstract && typeof(IPlugin).IsAssignableFrom(type)) - //Create the plugin instances - .Select(static type => (Activator.CreateInstance(type) as IPlugin)!); - //Load all plugins that implement the Iplugin interface - foreach (IPlugin plugin in plugins) + finally { - //Load wrapper - LivePlugin lp = new(plugin); - try - { - //Init config - lp.InitConfig(HostConfig, PluginConfigDOM); - //Init log handler - lp.InitLog(cliArgs); - //Load the plugin - lp.LoadPlugin(); - //Create new plugin loader for the plugin - LoadedPlugins.AddLast(lp); - } - catch (TargetInvocationException te) when (te.InnerException is not null) - { - throw te.InnerException; - } + pluginConfig?.Dispose(); } } + + /// <summary> + /// Loads all configured plugins by calling <see cref="IPlugin.Load"/> + /// event hook on the current thread. Loading exceptions are aggregated so not + /// to block individual loading. + /// </summary> + /// <exception cref="AggregateException"></exception> + public void LoadPlugins() + { + //Load all plugins + Controller.LoadPlugins(); + } + /// <summary> /// Manually reload the internal <see cref="PluginLoader"/> - /// which will reload the assembly and its plugins and endpoints + /// which will reload the assembly and its plugins /// </summary> - public void ReloadPlugin() => Loader.Reload(); + public void ReloadPlugins() => Loader.Reload(); /// <summary> /// Attempts to unload all plugins. /// </summary> /// <exception cref="AggregateException"></exception> - public void UnloadAll() => LoadedPlugins.TryForeach(lp => lp.UnloadPlugin(Log)); + public void UnloadAll() => Controller.UnloadPlugins(); ///<inheritdoc/> protected override void Free() { + Controller.Dispose(); Loader.Dispose(); - PluginConfigDOM?.Dispose(); } } diff --git a/lib/Plugins.Runtime/src/VNLib.Plugins.Runtime.csproj b/lib/Plugins.Runtime/src/VNLib.Plugins.Runtime.csproj index 37a9e74..87dbcfe 100644 --- a/lib/Plugins.Runtime/src/VNLib.Plugins.Runtime.csproj +++ b/lib/Plugins.Runtime/src/VNLib.Plugins.Runtime.csproj @@ -5,7 +5,6 @@ <TargetFramework>net6.0</TargetFramework> <RootNamespace>VNLib.Plugins.Runtime</RootNamespace> <AssemblyName>VNLib.Plugins.Runtime</AssemblyName> - <Version>1.0.1.1</Version> <AnalysisLevel>latest-all</AnalysisLevel> <RunAnalyzersDuringBuild>false</RunAnalyzersDuringBuild> <GenerateDocumentationFile>True</GenerateDocumentationFile> @@ -44,7 +43,7 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\Plugins.Essentials\src\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\Plugins\src\VNLib.Plugins.csproj" /> <ProjectReference Include="..\..\Utils\src\VNLib.Utils.csproj" /> </ItemGroup> diff --git a/lib/Utils/src/Extensions/JsonExtensions.cs b/lib/Utils/src/Extensions/JsonExtensions.cs index 523f772..55ad958 100644 --- a/lib/Utils/src/Extensions/JsonExtensions.cs +++ b/lib/Utils/src/Extensions/JsonExtensions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Utils @@ -64,19 +64,6 @@ namespace VNLib.Utils.Extensions public static class JsonExtensions { /// <summary> - /// Converts a JSON encoded string to an object of the specified type - /// </summary> - /// <typeparam name="T">Output type of the object</typeparam> - /// <param name="value"></param> - /// <param name="options"><see cref="JsonSerializerOptions"/> to use during de-serialization</param> - /// <returns>The new object or default if the string is null or empty</returns> - /// <exception cref="JsonException"></exception> - /// <exception cref="NotSupportedException"></exception> - public static T? AsJsonObject<T>(this string value, JsonSerializerOptions? options = null) - { - return !string.IsNullOrWhiteSpace(value) ? JsonSerializer.Deserialize<T>(value, options) : default; - } - /// <summary> /// Converts a JSON encoded binary data to an object of the specified type /// </summary> /// <typeparam name="T">Output type of the object</typeparam> @@ -159,18 +146,6 @@ namespace VNLib.Utils.Extensions } /// <summary> - /// Attemts to serialze an object to a JSON encoded string - /// </summary> - /// <param name="obj"></param> - /// <param name="options"><see cref="JsonSerializerOptions"/> to use during serialization</param> - /// <returns>A JSON encoded string of the serialized object, or null if the object is null</returns> - /// <exception cref="NotSupportedException"></exception> - public static string? ToJsonString<T>(this T obj, JsonSerializerOptions? options = null) - { - return obj == null ? null : JsonSerializer.Serialize(obj, options); - } - - /// <summary> /// Merges the current <see cref="JsonDocument"/> with another <see cref="JsonDocument"/> to /// create a new document of combined properties /// </summary> diff --git a/lib/Utils/src/VnEncoding.cs b/lib/Utils/src/VnEncoding.cs index 89863aa..c9cdbb0 100644 --- a/lib/Utils/src/VnEncoding.cs +++ b/lib/Utils/src/VnEncoding.cs @@ -74,67 +74,7 @@ namespace VNLib.Utils throw; } } - - /// <summary> - /// Attempts to deserialze a json object from a stream of UTF8 data - /// </summary> - /// <typeparam name="T">The type of the object to deserialize</typeparam> - /// <param name="data">Binary data to read from</param> - /// <param name="options"><see cref="JsonSerializerOptions"/> object to pass to deserializer</param> - /// <returns>The object decoded from the stream</returns> - /// <exception cref="JsonException"></exception> - /// <exception cref="NotSupportedException"></exception> - public static T? JSONDeserializeFromBinary<T>(Stream? data, JsonSerializerOptions? options = null) - { - //Return default if null - if (data == null) - { - return default; - } - //Create a memory stream as a buffer - using VnMemoryStream ms = new(); - //Copy stream data to memory - data.CopyTo(ms, null); - if (ms.Length > 0) - { - //Rewind - ms.Position = 0; - //Recover data from stream - return ms.AsSpan().AsJsonObject<T>(options); - } - //Stream is empty - return default; - } - /// <summary> - /// Attempts to deserialze a json object from a stream of UTF8 data - /// </summary> - /// <param name="data">Binary data to read from</param> - /// <param name="type"></param> - /// <param name="options"><see cref="JsonSerializerOptions"/> object to pass to deserializer</param> - /// <returns>The object decoded from the stream</returns> - /// <exception cref="JsonException"></exception> - /// <exception cref="NotSupportedException"></exception> - public static object? JSONDeserializeFromBinary(Stream? data, Type type, JsonSerializerOptions? options = null) - { - //Return default if null - if (data == null) - { - return default; - } - //Create a memory stream as a buffer - using VnMemoryStream ms = new(); - //Copy stream data to memory - data.CopyTo(ms, null); - if (ms.Length > 0) - { - //Rewind - ms.Position = 0; - //Recover data from stream - return JsonSerializer.Deserialize(ms.AsSpan(), type, options); - } - //Stream is empty - return default; - } + /// <summary> /// Attempts to deserialze a json object from a stream of UTF8 data /// </summary> @@ -315,7 +255,7 @@ namespace VNLib.Utils //Fill remaining bytes with padding chars for(; rounds < 8; rounds++) { - //Append trailing '=' + //Append trailing '=' padding character writer.Append('='); } } |