From de94d788e9a47432a7630a8215896b8dd3628599 Mon Sep 17 00:00:00 2001 From: vnugent Date: Sun, 8 Jan 2023 16:01:54 -0500 Subject: Reorder + analyzer cleanup --- lib/Plugins.Essentials/src/Accounts/AccountData.cs | 52 ++ .../src/Accounts/AccountManager.cs | 872 +++++++++++++++++++++ lib/Plugins.Essentials/src/Accounts/INonce.cs | 90 +++ .../src/Accounts/LoginMessage.cs | 102 +++ .../src/Accounts/PasswordHashing.cs | 244 ++++++ lib/Plugins.Essentials/src/Content/IPageRouter.cs | 43 + .../src/Endpoints/ProtectedWebEndpoint.cs | 58 ++ .../src/Endpoints/ProtectionSettings.cs | 103 +++ .../src/Endpoints/ResourceEndpointBase.cs | 346 ++++++++ .../src/Endpoints/UnprotectedWebEndpoint.cs | 42 + .../src/Endpoints/VirtualEndpoint.cs | 67 ++ lib/Plugins.Essentials/src/EventProcessor.cs | 727 +++++++++++++++++ .../src/Extensions/CollectionsExtensions.cs | 85 ++ .../src/Extensions/ConnectionInfoExtensions.cs | 361 +++++++++ .../src/Extensions/EssentialHttpEventExtensions.cs | 848 ++++++++++++++++++++ .../src/Extensions/IJsonSerializerBuffer.cs | 48 ++ .../src/Extensions/InternalSerializerExtensions.cs | 100 +++ .../src/Extensions/InvalidJsonRequestException.cs | 57 ++ .../src/Extensions/JsonResponse.cs | 112 +++ .../src/Extensions/RedirectType.cs | 37 + .../src/Extensions/SimpleMemoryResponse.cs | 89 +++ .../src/Extensions/UserExtensions.cs | 94 +++ lib/Plugins.Essentials/src/FileProcessArgs.cs | 169 ++++ lib/Plugins.Essentials/src/HttpEntity.cs | 178 +++++ lib/Plugins.Essentials/src/IEpProcessingOptions.cs | 66 ++ .../src/Oauth/IOAuth2Provider.cs | 44 ++ lib/Plugins.Essentials/src/Oauth/O2EndpointBase.cs | 162 ++++ .../src/Oauth/OauthHttpExtensions.cs | 239 ++++++ .../Oauth/OauthSessionCacheExhaustedException.cs | 43 + .../src/Oauth/OauthSessionExtensions.cs | 88 +++ lib/Plugins.Essentials/src/Sessions/ISession.cs | 94 +++ .../src/Sessions/ISessionExtensions.cs | 95 +++ .../src/Sessions/ISessionProvider.cs | 49 ++ lib/Plugins.Essentials/src/Sessions/SessionBase.cs | 168 ++++ .../src/Sessions/SessionCacheLimitException.cs | 41 + .../src/Sessions/SessionException.cs | 48 ++ .../src/Sessions/SessionHandle.cs | 123 +++ lib/Plugins.Essentials/src/Sessions/SessionInfo.cs | 231 ++++++ lib/Plugins.Essentials/src/Statics.cs | 44 ++ lib/Plugins.Essentials/src/TimestampedCounter.cs | 117 +++ lib/Plugins.Essentials/src/Users/IUser.cs | 74 ++ lib/Plugins.Essentials/src/Users/IUserManager.cs | 103 +++ .../src/Users/UserCreationFailedException.cs | 47 ++ .../src/Users/UserDeleteException.cs | 44 ++ .../src/Users/UserExistsException.cs | 49 ++ lib/Plugins.Essentials/src/Users/UserStatus.cs | 50 ++ .../src/Users/UserUpdateException.cs | 43 + .../src/VNLib.Plugins.Essentials.csproj | 53 ++ lib/Plugins.Essentials/src/WebSocketSession.cs | 204 +++++ 49 files changed, 7243 insertions(+) create mode 100644 lib/Plugins.Essentials/src/Accounts/AccountData.cs create mode 100644 lib/Plugins.Essentials/src/Accounts/AccountManager.cs create mode 100644 lib/Plugins.Essentials/src/Accounts/INonce.cs create mode 100644 lib/Plugins.Essentials/src/Accounts/LoginMessage.cs create mode 100644 lib/Plugins.Essentials/src/Accounts/PasswordHashing.cs create mode 100644 lib/Plugins.Essentials/src/Content/IPageRouter.cs create mode 100644 lib/Plugins.Essentials/src/Endpoints/ProtectedWebEndpoint.cs create mode 100644 lib/Plugins.Essentials/src/Endpoints/ProtectionSettings.cs create mode 100644 lib/Plugins.Essentials/src/Endpoints/ResourceEndpointBase.cs create mode 100644 lib/Plugins.Essentials/src/Endpoints/UnprotectedWebEndpoint.cs create mode 100644 lib/Plugins.Essentials/src/Endpoints/VirtualEndpoint.cs create mode 100644 lib/Plugins.Essentials/src/EventProcessor.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/CollectionsExtensions.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/ConnectionInfoExtensions.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/IJsonSerializerBuffer.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/InternalSerializerExtensions.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/InvalidJsonRequestException.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/JsonResponse.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/RedirectType.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/SimpleMemoryResponse.cs create mode 100644 lib/Plugins.Essentials/src/Extensions/UserExtensions.cs create mode 100644 lib/Plugins.Essentials/src/FileProcessArgs.cs create mode 100644 lib/Plugins.Essentials/src/HttpEntity.cs create mode 100644 lib/Plugins.Essentials/src/IEpProcessingOptions.cs create mode 100644 lib/Plugins.Essentials/src/Oauth/IOAuth2Provider.cs create mode 100644 lib/Plugins.Essentials/src/Oauth/O2EndpointBase.cs create mode 100644 lib/Plugins.Essentials/src/Oauth/OauthHttpExtensions.cs create mode 100644 lib/Plugins.Essentials/src/Oauth/OauthSessionCacheExhaustedException.cs create mode 100644 lib/Plugins.Essentials/src/Oauth/OauthSessionExtensions.cs create mode 100644 lib/Plugins.Essentials/src/Sessions/ISession.cs create mode 100644 lib/Plugins.Essentials/src/Sessions/ISessionExtensions.cs create mode 100644 lib/Plugins.Essentials/src/Sessions/ISessionProvider.cs create mode 100644 lib/Plugins.Essentials/src/Sessions/SessionBase.cs create mode 100644 lib/Plugins.Essentials/src/Sessions/SessionCacheLimitException.cs create mode 100644 lib/Plugins.Essentials/src/Sessions/SessionException.cs create mode 100644 lib/Plugins.Essentials/src/Sessions/SessionHandle.cs create mode 100644 lib/Plugins.Essentials/src/Sessions/SessionInfo.cs create mode 100644 lib/Plugins.Essentials/src/Statics.cs create mode 100644 lib/Plugins.Essentials/src/TimestampedCounter.cs create mode 100644 lib/Plugins.Essentials/src/Users/IUser.cs create mode 100644 lib/Plugins.Essentials/src/Users/IUserManager.cs create mode 100644 lib/Plugins.Essentials/src/Users/UserCreationFailedException.cs create mode 100644 lib/Plugins.Essentials/src/Users/UserDeleteException.cs create mode 100644 lib/Plugins.Essentials/src/Users/UserExistsException.cs create mode 100644 lib/Plugins.Essentials/src/Users/UserStatus.cs create mode 100644 lib/Plugins.Essentials/src/Users/UserUpdateException.cs create mode 100644 lib/Plugins.Essentials/src/VNLib.Plugins.Essentials.csproj create mode 100644 lib/Plugins.Essentials/src/WebSocketSession.cs (limited to 'lib/Plugins.Essentials/src') diff --git a/lib/Plugins.Essentials/src/Accounts/AccountData.cs b/lib/Plugins.Essentials/src/Accounts/AccountData.cs new file mode 100644 index 0000000..d4a4d12 --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/AccountData.cs @@ -0,0 +1,52 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: AccountData.cs +* +* AccountData.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.Text.Json.Serialization; + +namespace VNLib.Plugins.Essentials.Accounts +{ + public class AccountData + { + [JsonPropertyName("email")] + public string EmailAddress { get; set; } + [JsonPropertyName("phone")] + public string PhoneNumber { get; set; } + [JsonPropertyName("first")] + public string First { get; set; } + [JsonPropertyName("last")] + public string Last { get; set; } + [JsonPropertyName("company")] + public string Company { get; set; } + [JsonPropertyName("street")] + public string Street { get; set; } + [JsonPropertyName("city")] + public string City { get; set; } + [JsonPropertyName("state")] + public string State { get; set; } + [JsonPropertyName("zip")] + public string Zip { get; set; } + [JsonPropertyName("created")] + public string Created { get; set; } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/AccountManager.cs b/lib/Plugins.Essentials/src/Accounts/AccountManager.cs new file mode 100644 index 0000000..f148fdb --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/AccountManager.cs @@ -0,0 +1,872 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: AccountManager.cs +* +* AccountManager.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 System.IO; +using System.Text; +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 + +namespace VNLib.Plugins.Essentials.Accounts +{ + + /// + /// Provides essential constants, static methods, and session/user extensions + /// to facilitate unified user-controls, athentication, and security + /// application-wide + /// + public static partial class AccountManager + { + 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; + + /// + /// The maximum time in seconds for a login message to be considered valid + /// + public const double MAX_TIME_DIFF_SECS = 10.00; + /// + /// The size in bytes of the random passwords generated when invoking the + /// + public const int RANDOM_PASS_SIZE = 128; + /// + /// The name of the header that will identify a client's identiy + /// + public const string LOGIN_TOKEN_HEADER = "X-Web-Token"; + /// + /// 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 + /// + public const string LOCAL_ACCOUNT_ORIGIN = "local"; + /// + /// The size (in bytes) of the challenge secret + /// + public const int CHALLENGE_SIZE = 64; + /// + /// The size (in bytes) of the sesssion long user-password challenge + /// + 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; + /// + /// The name of the login cookie set when a user logs in + /// + public const string LOGIN_COOKIE_NAME = "VNLogin"; + /// + /// The name of the login client identifier cookie (cookie that is set fir client to use to determine if the user is logged in) + /// + 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 CHALLENGE_HASH_ENTRY = "acnt.chl"; + + //Privlage masks + public const ulong READ_MSK = 0x0000000000000001L; + public const ulong DOWNLOAD_MSK = 0x0000000000000002L; + public const ulong WRITE_MSK = 0x0000000000000004L; + public const ulong DELETE_MSK = 0x0000000000000008L; + public const ulong ALLFILE_MSK = 0x000000000000000FL; + public const ulong OPTIONS_MSK = 0x000000000000FF00L; + public const ulong GROUP_MSK = 0x00000000FFFF0000L; + public const ulong LEVEL_MSK = 0x000000FF00000000L; + + public const byte OPTIONS_MSK_OFFSET = 0x08; + public const byte GROUP_MSK_OFFSET = 0x10; + public const byte LEVEL_MSK_OFFSET = 0x18; + + public const ulong MINIMUM_LEVEL = 0x0000000100000001L; + + //Timeouts + public static readonly TimeSpan LoginCookieLifespan = TimeSpan.FromHours(1); + public static readonly TimeSpan RegenIdPeriod = TimeSpan.FromMinutes(25); + + /// + /// The client data encryption padding. + /// + public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; + + /// + /// The size (in bytes) of the web-token hash size + /// + private static readonly int TokenHashSize = (SHA384.Create().HashSize / 8); + + /// + /// Speical character regual expresion for basic checks + /// + public static readonly Regex SpecialCharacters = new(@"[\r\n\t\a\b\e\f#?!@$%^&*\+\-\~`|<>\{}]", RegexOptions.Compiled); + + #region Password/User helper extensions + + /// + /// Generates and sets a random password for the specified user account + /// + /// The configured to process the password update on + /// The user instance to update the password on + /// The instance to hash the random password with + /// Size (in bytes) of the generated random password + /// A value indicating the results of the event (number of rows affected, should evaluate to true) + /// + /// + /// + public static async Task SetRandomPasswordAsync(this PasswordHashing passHashing, IUserManager manager, IUser user, int size = RANDOM_PASS_SIZE) + { + _ = manager ?? throw new ArgumentNullException(nameof(manager)); + _ = user ?? throw new ArgumentNullException(nameof(user)); + _ = passHashing ?? throw new ArgumentNullException(nameof(passHashing)); + if (user.IsReleased) + { + throw new ObjectDisposedException("The specifed user object has been released"); + } + //Alloc a buffer + using IMemoryHandle buffer = Memory.SafeAlloc(size); + //Use the CGN to get a random set + RandomHash.GetRandomBytes(buffer.Span); + //Hash the new random password + using PrivateString passHash = passHashing.Hash(buffer.Span); + //Write the password to the user account + return await manager.UpdatePassAsync(user, passHash); + } + + + /// + /// Checks to see if the current user account was created + /// using a local account. + /// + /// + /// True if the account is a local account, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsLocalAccount(this IUser user) => LOCAL_ACCOUNT_ORIGIN.Equals(user.GetAccountOrigin(), StringComparison.Ordinal); + + /// + /// If this account was created by any means other than a local account creation. + /// Implementors can use this method to determine the origin of the account. + /// This field is not required + /// + /// The origin of the account + public static string GetAccountOrigin(this IUser ud) => ud[ACC_ORIGIN_ENTRY]; + /// + /// If this account was created by any means other than a local account creation. + /// Implementors can use this method to specify the origin of the account. This field is not required + /// + /// + /// Value of the account origin + public static void SetAccountOrigin(this IUser ud, string origin) => ud[ACC_ORIGIN_ENTRY] = origin; + + /// + /// Gets a random user-id generated from crypograhic random number + /// then hashed (SHA1) and returns a hexadecimal string + /// + /// The random string user-id + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetRandomUserId() => RandomHash.GetRandomHash(HashAlg.SHA1, 64, HashEncodingMode.Hexadecimal); + + #endregion + + #region Client Auth Extensions + + /// + /// Runs necessary operations to grant authorization to the specified user of a given session and user with provided variables + /// + /// The connection and session to log-in + /// The message of the client to set the log-in status of + /// The user to log-in + /// The encrypted base64 token secret data to send to the client + /// + /// + public static string GenerateAuthorization(this HttpEntity ev, LoginMessage loginMessage, IUser user) + { + return GenerateAuthorization(ev, loginMessage.ClientPublicKey, loginMessage.ClientID, user); + } + + /// + /// Runs necessary operations to grant authorization to the specified user of a given session and user with provided variables + /// + /// The connection and session to log-in + /// The clients base64 public key + /// The browser/client id + /// The user to log-in + /// The encrypted base64 token secret data to send to the client + /// + /// + /// + public static string GenerateAuthorization(this HttpEntity ev, string base64PubKey, string clientId, IUser user) + { + if (!ev.Session.IsSet || ev.Session.SessionType != SessionType.Web) + { + throw new InvalidOperationException("The session is not set or the session is not a web-based session type"); + } + //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; + //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 + { + public readonly Span Buffer { private get; init; } + public readonly Span SignatureBuffer => Buffer[..64]; + + + + public int ClientPbkWritten; + public readonly Span ClientPublicKeyBuffer => Buffer.Slice(64, 1024); + public readonly ReadOnlySpan ClientPbkOutput => ClientPublicKeyBuffer[..ClientPbkWritten]; + + + + public int ClientEncBytesWritten; + public readonly Span ClientEncOutputBuffer => Buffer[(64 + 1024)..]; + public readonly ReadOnlySpan EncryptedOutput => ClientEncOutputBuffer[..ClientEncBytesWritten]; + } + + /// + /// 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 + /// + /// The user's public key credential + /// The base64 encoded digest of the secret that was encrypted + /// The client's user-agent header value + /// A string representing a unique signed token for a given login context + /// + /// + private static void TryGenerateToken(string base64clientPublicKey, out string base64Digest, out string base64ClientData) + { + //Temporary work buffer + using IMemoryHandle buffer = Memory.SafeAlloc(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); + } + + /// + /// Determines if the client sent a token header, and it maches against the current session + /// + /// true if the client set the token header, the session is loaded, and the token matches the session, false otherwise + public static bool TokenMatches(this HttpEntity ev) + { + //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 buffer = Memory.UnsafeAlloc(TokenHashSize * 2, true); + //Slice up buffers + Span headerBuffer = buffer.Span[..TokenHashSize]; + Span 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) + return CryptographicOperations.FixedTimeEquals(headerBuffer[..headerTokenLen], sessionBuffer[..sessionTokenLen]); + } + return false; + } + + /// + /// Regenerates the user's login token with the public key stored + /// during initial logon + /// + /// The base64 of the newly encrypted secret + public static string? RegenerateClientToken(this HttpEntity ev) + { + 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; + //return the clients encrypted secret + return base64ClientData; + } + + /// + /// Tries to encrypt the specified data using the stored public key and store the encrypted data into + /// the output buffer. + /// + /// + /// Data to encrypt + /// The buffer to store encrypted data in + /// + /// The number of encrypted bytes written to the output buffer, + /// or false (0) if the operation failed, or if no credential is + /// stored. + /// + /// + public static ERRNO TryEncryptClientData(this in SessionInfo session, ReadOnlySpan data, in Span outputBuffer) + { + if (!session.IsSet) + { + return false; + } + //try to get the public key from the client + string base64PubKey = session.GetBrowserPubKey(); + return TryEncryptClientData(base64PubKey, data, in outputBuffer); + } + /// + /// Tries to encrypt the specified data using the specified public key + /// + /// A base64 encoded public key used to encrypt client data + /// Data to encrypt + /// The buffer to store encrypted data in + /// + /// The number of encrypted bytes written to the output buffer, + /// or false (0) if the operation failed, or if no credential is + /// specified. + /// + /// + public static ERRNO TryEncryptClientData(ReadOnlySpan base64PubKey, ReadOnlySpan data, in Span outputBuffer) + { + if (base64PubKey.IsEmpty) + { + return false; + } + //Alloc a buffer for decoding the public key + using UnsafeMemoryHandle pubKeyBuffer = Memory.UnsafeAlloc(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; + } + /// + /// Tries to encrypt the specified data using the specified public key + /// + /// The raw SKI public key + /// Data to encrypt + /// The buffer to store encrypted data in + /// + /// The number of encrypted bytes written to the output buffer, + /// or false (0) if the operation failed, or if no credential is + /// specified. + /// + /// + public static ERRNO TryEncryptClientData(ReadOnlySpan rawPubKey, ReadOnlySpan data, in Span outputBuffer) + { + if (rawPubKey.IsEmpty) + { + return false; + } + //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; + } + + /// + /// Stores the clients public key specified during login + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetBrowserPubKey(in SessionInfo session, string base64PubKey) => session[CLIENT_PUB_KEY_ENTRY] = base64PubKey; + + /// + /// Gets the clients stored public key that was specified during login + /// + /// The base64 encoded public key string specified at login + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetBrowserPubKey(this in SessionInfo session) => session[CLIENT_PUB_KEY_ENTRY]; + + /// + /// Stores the login key as a cookie in the current session as long as the session exists + /// / + /// The event to log-in + /// Does the session belong to a local user account + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetLogin(this HttpEntity ev, bool? localAccount = null) + { + //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); + } + + /// + /// Invalidates the login status of the current connection and session (if session is loaded) + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void InvalidateLogin(this HttpEntity ev) + { + //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(); + } + } + + /// + /// Determines if the current session login cookie matches the value stored in the current session (if the session is loaded) + /// + /// True if the session is active, the cookie was properly received, and the cookie value matches the session. False otherwise + 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 buffer = Memory.UnsafeAlloc(2 * LOGIN_COOKIE_SIZE, true); + //Slice up buffers + Span cookieBuffer = buffer.Span[..LOGIN_COOKIE_SIZE]; + Span 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) < DateTimeOffset.UtcNow) + { + //Regen login token + ev.SetLogin(); + ev.Session.RegenID(); + } + + return true; + } + } + return false; + } + + /// + /// Determines if the client's login cookies need to be updated + /// to reflect its state with the current session's state + /// for the client + /// + /// + public static void ReconcileCookies(this HttpEntity ev) + { + //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)) + { + //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); + } + } + } + + + /// + /// Stores the browser's id during a login process + /// + /// + /// Browser id value to store + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetBrowserID(in SessionInfo session, string browserId) => session[BROWSER_ID_ENTRY] = browserId; + + /// + /// Gets the current browser's id if it was specified during login process + /// + /// The browser's id if set, otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetBrowserID(this in SessionInfo session) => session[BROWSER_ID_ENTRY]; + + /// + /// Specifies that the current session belongs to a local user-account + /// + /// + /// True for a local account, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void HasLocalAccount(this in SessionInfo session, bool value) => session[LOCAL_ACCOUNT_ENTRY] = value ? "1" : null; + /// + /// Gets a value indicating if the session belongs to a local user account + /// + /// + /// True if the current user's account is a local account + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasLocalAccount(this in SessionInfo session) => int.TryParse(session[LOCAL_ACCOUNT_ENTRY], out int value) && value > 0; + + #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 + */ + + /// + /// Generates a new password challenge for the current session and specified password + /// + /// + /// The user's password to compute the hash of + /// The raw derrivation key to send to the client + public static byte[] GenPasswordChallenge(this in SessionInfo session, PrivateString password) + { + ReadOnlySpan rawPass = password; + //Calculate the password buffer size required + int passByteCount = Encoding.UTF8.GetByteCount(rawPass); + //Allocate the buffer + using UnsafeMemoryHandle bufferHandle = Memory.UnsafeAlloc(passByteCount + 64, true); + //Slice buffers + Span utf8PassBytes = bufferHandle.Span[..passByteCount]; + Span 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); + } + } + /// + /// Verifies the stored unique digest of the user's password against + /// the client derrived password + /// + /// + /// The base64 client derrived digest of the user's password to verify + /// True if formatting was correct and the derrived passwords match, false otherwise + /// + public static bool VerifyChallenge(this in SessionInfo session, ReadOnlySpan base64PasswordDigest) + { + string base32Digest = session[CHALLENGE_HMAC_ENTRY]; + if (string.IsNullOrWhiteSpace(base32Digest)) + { + return false; + } + int bufSize = base32Digest.Length + base64PasswordDigest.Length; + //Alloc buffer + using UnsafeMemoryHandle buffer = Memory.UnsafeAlloc(bufSize); + //Split buffers + Span localBuf = buffer.Span[..base32Digest.Length]; + Span 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 + /// + /// Compares the users privilage level against the specified level + /// + /// + /// 64bit privilage level to compare + /// true if the current user has at least the specified level or higher + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasLevel(this in SessionInfo session, byte level) => (session.Privilages & LEVEL_MSK) >= (((ulong)level << LEVEL_MSK_OFFSET) & LEVEL_MSK); + /// + /// Determines if the group ID of the current user matches the specified group + /// + /// + /// Group ID to compare + /// true if the user belongs to the group, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasGroup(this in SessionInfo session, ushort groupId) => (session.Privilages & GROUP_MSK) == (((ulong)groupId << GROUP_MSK_OFFSET) & GROUP_MSK); + /// + /// Determines if the current user has an equivalent option code + /// + /// + /// Option code check + /// true if the user options field equals the option + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasOption(this in SessionInfo session, byte option) => (session.Privilages & OPTIONS_MSK) == (((ulong)option << OPTIONS_MSK_OFFSET) & OPTIONS_MSK); + + /// + /// Returns the status of the user's privlage read bit + /// + /// true if the current user has the read permission, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool CanRead(this in SessionInfo session) => (session.Privilages & READ_MSK) == READ_MSK; + /// + /// Returns the status of the user's privlage write bit + /// + /// true if the current user has the write permission, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool CanWrite(this in SessionInfo session) => (session.Privilages & WRITE_MSK) == WRITE_MSK; + /// + /// Returns the status of the user's privlage delete bit + /// + /// true if the current user has the delete permission, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool CanDelete(this in SessionInfo session) => (session.Privilages & DELETE_MSK) == DELETE_MSK; + #endregion + + #region flc + + /// + /// Gets the current number of failed login attempts + /// + /// + /// The current number of failed login attempts + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TimestampedCounter FailedLoginCount(this IUser user) + { + ulong value = user.GetValueType(FAILED_LOGIN_ENTRY); + return (TimestampedCounter)value; + } + /// + /// Sets the number of failed login attempts for the current session + /// + /// + /// The value to set the failed login attempt count + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void FailedLoginCount(this IUser user, uint value) + { + TimestampedCounter counter = new(value); + //Cast the counter to a ulong and store as a ulong + user.SetValueType(FAILED_LOGIN_ENTRY, (ulong)counter); + } + /// + /// Sets the number of failed login attempts for the current session + /// + /// + /// The value to set the failed login attempt count + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void FailedLoginCount(this IUser user, TimestampedCounter value) + { + //Cast the counter to a ulong and store as a ulong + user.SetValueType(FAILED_LOGIN_ENTRY, (ulong)value); + } + /// + /// Increments the failed login attempt count + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void FailedLoginIncrement(this IUser user) + { + TimestampedCounter current = user.FailedLoginCount(); + user.FailedLoginCount(current.Count + 1); + } + + #endregion + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/INonce.cs b/lib/Plugins.Essentials/src/Accounts/INonce.cs new file mode 100644 index 0000000..7d53183 --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/INonce.cs @@ -0,0 +1,90 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: INonce.cs +* +* INonce.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 +{ + /// + /// Represents a object that performs storage and computation of nonce values + /// + public interface INonce + { + /// + /// Generates a random nonce for the current instance and + /// returns a base32 encoded string. + /// + /// The buffer to write a copy of the nonce value to + void ComputeNonce(Span buffer); + /// + /// Compares the raw nonce bytes to the current nonce to determine + /// if the supplied nonce value is valid + /// + /// The binary value of the nonce + /// True if the nonce values are equal, flase otherwise + bool VerifyNonce(ReadOnlySpan nonceBytes); + } + + /// + /// Provides INonce extensions for computing/verifying nonce values + /// + public static class NonceExtensions + { + /// + /// Computes a base32 nonce of the specified size and returns a string + /// representation + /// + /// + /// The size (in bytes) of the nonce + /// The base32 string of the computed nonce + public static string ComputeNonce(this T nonce, int size) where T: INonce + { + //Alloc bin buffer + using UnsafeMemoryHandle buffer = Memory.UnsafeAlloc(size); + //Compute nonce + nonce.ComputeNonce(buffer.Span); + //Return base32 string + return VnEncoding.ToBase32String(buffer.Span, false); + } + /// + /// Compares the base32 encoded nonce value against the previously + /// generated nonce + /// + /// + /// The base32 encoded nonce string + /// True if the nonce values are equal, flase otherwise + public static bool VerifyNonce(this T nonce, ReadOnlySpan base32Nonce) where T : INonce + { + //Alloc bin buffer + using UnsafeMemoryHandle buffer = Memory.UnsafeAlloc(base32Nonce.Length); + //Decode base32 nonce + ERRNO count = VnEncoding.TryFromBase32Chars(base32Nonce, buffer.Span); + //Verify nonce + return nonce.VerifyNonce(buffer.Span[..(int)count]); + } + } +} diff --git a/lib/Plugins.Essentials/src/Accounts/LoginMessage.cs b/lib/Plugins.Essentials/src/Accounts/LoginMessage.cs new file mode 100644 index 0000000..ebc616e --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/LoginMessage.cs @@ -0,0 +1,102 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: LoginMessage.cs +* +* LoginMessage.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 System.Text.Json.Serialization; +using VNLib.Utils.Memory; + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// + /// A uniform JSON login message for the + /// accounts provider to use + /// + /// + /// NOTE: This class derrives from + /// and should be disposed properly + /// + public class LoginMessage : PrivateStringManager + { + /// + /// A property + /// + [JsonPropertyName("username")] + public string UserName { get; set; } + /// + /// A protected string property that + /// may represent a user's password + /// + [JsonPropertyName("password")] + public string Password + { + get => base[0]; + set => base[0] = value; + } + [JsonPropertyName("localtime")] + public string Lt + { + get => LocalTime.ToString("O"); + //Try to parse the supplied time string, and use the datetime.min if the time string is invalid + set => LocalTime = DateTimeOffset.TryParse(value, out DateTimeOffset local) ? local : DateTimeOffset.MinValue; + } + + /// + /// Represents the clients local time in a struct + /// + [JsonIgnore] + public DateTimeOffset LocalTime { get; set; } + /// + /// The clients specified local-language + /// + [JsonPropertyName("locallanguage")] + public string LocalLanguage { get; set; } + /// + /// The clients shared public key used for encryption, this property is not protected + /// + [JsonPropertyName("pubkey")] + public string ClientPublicKey { get; set; } + /// + /// The clients browser id if shared + /// + [JsonPropertyName("clientid")] + public string ClientID { get; set; } + /// + /// Initailzies a new and its parent + /// base + /// + public LoginMessage() : this(1) { } + /// + /// Allows for derrives classes to have multple protected + /// string elements + /// + /// + /// The number of procted string elements required + /// + /// + /// NOTE: must be at-least 1 + /// or access to will throw + /// + protected LoginMessage(int protectedElementSize = 1) : base(protectedElementSize) { } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Accounts/PasswordHashing.cs b/lib/Plugins.Essentials/src/Accounts/PasswordHashing.cs new file mode 100644 index 0000000..1c3770b --- /dev/null +++ b/lib/Plugins.Essentials/src/Accounts/PasswordHashing.cs @@ -0,0 +1,244 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: PasswordHashing.cs +* +* PasswordHashing.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 System.Security.Cryptography; + +using VNLib.Hashing; +using VNLib.Utils; +using VNLib.Utils.Memory; + +namespace VNLib.Plugins.Essentials.Accounts +{ + /// + /// A delegate method to recover a temporary copy of the secret/pepper + /// for a request + /// + /// The buffer to write the pepper to + /// The number of bytes written to the buffer + public delegate ERRNO SecretAction(Span buffer); + + /// + /// Provides a structrued password hashing system implementing the library + /// with fixed time comparison + /// + public sealed class PasswordHashing + { + private readonly SecretAction _getter; + private readonly int _secretSize; + + private readonly uint TimeCost; + private readonly uint MemoryCost; + private readonly uint HashLen; + private readonly int SaltLen; + private readonly uint Parallelism; + + /// + /// Initalizes the class + /// + /// + /// The expected size of the secret (the size of the buffer to alloc for a copy) + /// A positive integer for the size of the random salt used during the hashing proccess + /// The Argon2 time cost parameter + /// The Argon2 memory cost parameter + /// The size of the hash to produce during hashing operations + /// + /// The Argon2 parallelism parameter (the number of threads to use for hasing) + /// (default = 0 - the number of processors) + /// + /// + /// + public PasswordHashing(SecretAction getter, int secreteSize, int saltLen = 32, uint timeCost = 4, uint memoryCost = UInt16.MaxValue, uint parallism = 0, uint hashLen = 128) + { + //Store getter + _getter = getter ?? throw new ArgumentNullException(nameof(getter)); + _secretSize = secreteSize; + + //Store parameters + HashLen = hashLen; + //Store maginitude as a unit + MemoryCost = memoryCost; + TimeCost = timeCost; + SaltLen = saltLen; + Parallelism = parallism < 1 ? (uint)Environment.ProcessorCount : parallism; + } + + /// + /// Verifies a password against its previously encoded hash. + /// + /// Previously hashed password + /// Raw password to compare against + /// true if bytes derrived from password match the hash, false otherwise + /// + /// + /// + public bool Verify(PrivateString passHash, PrivateString password) + { + //Casting PrivateStrings to spans will reference the base string directly + return Verify((ReadOnlySpan)passHash, (ReadOnlySpan)password); + } + /// + /// Verifies a password against its previously encoded hash. + /// + /// Previously hashed password + /// Raw password to compare against + /// true if bytes derrived from password match the hash, false otherwise + /// + /// + /// + public bool Verify(ReadOnlySpan passHash, ReadOnlySpan password) + { + if(passHash.IsEmpty || password.IsEmpty) + { + return false; + } + //alloc secret buffer + using UnsafeMemoryHandle secretBuffer = Memory.UnsafeAlloc(_secretSize, true); + try + { + //Get the secret from the callback + ERRNO count = _getter(secretBuffer.Span); + //Verify + return VnArgon2.Verify2id(password, passHash, secretBuffer.Span[..(int)count]); + } + finally + { + //Erase secret buffer + Memory.InitializeBlock(secretBuffer.Span); + } + } + /// + /// Verifies a password against its hash. Partially exposes the Argon2 api. + /// + /// Previously hashed password + /// The salt used to hash the original password + /// The password to hash and compare against + /// true if bytes derrived from password match the hash, false otherwise + /// + /// Uses fixed time comparison from class + public bool Verify(ReadOnlySpan hash, ReadOnlySpan salt, ReadOnlySpan password) + { + //Alloc a buffer with the same size as the hash + using UnsafeMemoryHandle hashBuf = Memory.UnsafeAlloc(hash.Length, true); + //Hash the password with the current config + Hash(password, salt, hashBuf.Span); + //Compare the hashed password to the specified hash and return results + return CryptographicOperations.FixedTimeEquals(hash, hashBuf.Span); + } + + /// + /// Hashes a specified password, with the initialized pepper, and salted with CNG random bytes. + /// + /// Password to be hashed + /// + /// A of the hashed and encoded password + public PrivateString Hash(PrivateString password) => Hash((ReadOnlySpan)password); + + /// + /// Hashes a specified password, with the initialized pepper, and salted with CNG random bytes. + /// + /// Password to be hashed + /// + /// A of the hashed and encoded password + public PrivateString Hash(ReadOnlySpan password) + { + //Alloc shared buffer for the salt and secret buffer + using UnsafeMemoryHandle buffer = Memory.UnsafeAlloc(SaltLen + _secretSize, true); + try + { + //Split buffers + Span saltBuf = buffer.Span[..SaltLen]; + Span secretBuf = buffer.Span[SaltLen..]; + + //Fill the buffer with random bytes + RandomHash.GetRandomBytes(saltBuf); + + //recover the secret + ERRNO count = _getter(secretBuf); + + //Hashes a password, with the current parameters + return (PrivateString)VnArgon2.Hash2id(password, saltBuf, secretBuf[..(int)count], TimeCost, MemoryCost, Parallelism, HashLen); + } + finally + { + Memory.InitializeBlock(buffer.Span); + } + } + + /// + /// Hashes a specified password, with the initialized pepper, and salted with a CNG random bytes. + /// + /// Password to be hashed + /// + /// A of the hashed and encoded password + public PrivateString Hash(ReadOnlySpan password) + { + using UnsafeMemoryHandle buffer = Memory.UnsafeAlloc(SaltLen + _secretSize, true); + try + { + //Split buffers + Span saltBuf = buffer.Span[..SaltLen]; + Span secretBuf = buffer.Span[SaltLen..]; + + //Fill the buffer with random bytes + RandomHash.GetRandomBytes(saltBuf); + + //recover the secret + ERRNO count = _getter(secretBuf); + + //Hashes a password, with the current parameters + return (PrivateString)VnArgon2.Hash2id(password, saltBuf, secretBuf[..(int)count], TimeCost, MemoryCost, Parallelism, HashLen); + } + finally + { + Memory.InitializeBlock(buffer.Span); + } + } + /// + /// Partially exposes the Argon2 api. Hashes the specified password, with the initialized pepper. + /// Writes the raw hash output to the specified buffer + /// + /// Password to be hashed + /// Salt to hash the password with + /// The output buffer to store the hashed password to. The exact length of this buffer is the hash size + /// + public void Hash(ReadOnlySpan password, ReadOnlySpan salt, Span hashOutput) + { + //alloc secret buffer + using UnsafeMemoryHandle secretBuffer = Memory.UnsafeAlloc(_secretSize, true); + try + { + //Get the secret from the callback + ERRNO count = _getter(secretBuffer.Span); + //Hashes a password, with the current parameters + VnArgon2.Hash2id(password, salt, secretBuffer.Span[..(int)count], hashOutput, TimeCost, MemoryCost, Parallelism); + } + finally + { + //Erase secret buffer + Memory.InitializeBlock(secretBuffer.Span); + } + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Content/IPageRouter.cs b/lib/Plugins.Essentials/src/Content/IPageRouter.cs new file mode 100644 index 0000000..e6952f4 --- /dev/null +++ b/lib/Plugins.Essentials/src/Content/IPageRouter.cs @@ -0,0 +1,43 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IPageRouter.cs +* +* IPageRouter.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.Threading.Tasks; + +//Import account system for privilage masks + +namespace VNLib.Plugins.Essentials.Content +{ + /// + /// Determines file routines (routing) for incomming connections + /// + public interface IPageRouter + { + /// + /// Determines what file path to return to a user for the given incoming connection + /// + /// The connection to proccess + /// A that returns the to pass to the file processor + ValueTask RouteAsync(HttpEntity entity); + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Endpoints/ProtectedWebEndpoint.cs b/lib/Plugins.Essentials/src/Endpoints/ProtectedWebEndpoint.cs new file mode 100644 index 0000000..bced960 --- /dev/null +++ b/lib/Plugins.Essentials/src/Endpoints/ProtectedWebEndpoint.cs @@ -0,0 +1,58 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: ProtectedWebEndpoint.cs +* +* ProtectedWebEndpoint.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 System.Net; + +using VNLib.Utils; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Essentials.Endpoints +{ + /// + /// Implements to provide + /// authoriation checks before processing + /// + public abstract class ProtectedWebEndpoint : UnprotectedWebEndpoint + { + /// + protected override ERRNO PreProccess(HttpEntity entity) + { + if (!base.PreProccess(entity)) + { + return false; + } + //The loggged in flag must be set, and the token must also match + if (!entity.LoginCookieMatches() || !entity.TokenMatches()) + { + //Return unauthorized status + entity.CloseResponse(HttpStatusCode.Unauthorized); + //A return value less than 0 signals a virtual skip event + return -1; + } + //Continue + return true; + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Endpoints/ProtectionSettings.cs b/lib/Plugins.Essentials/src/Endpoints/ProtectionSettings.cs new file mode 100644 index 0000000..77620ac --- /dev/null +++ b/lib/Plugins.Essentials/src/Endpoints/ProtectionSettings.cs @@ -0,0 +1,103 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: ProtectionSettings.cs +* +* ProtectionSettings.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; + +namespace VNLib.Plugins.Essentials.Endpoints +{ + /// + /// A data structure containing a basic security protocol + /// for connection pre-checks. Settings are the most + /// strict by default + /// + public readonly struct ProtectionSettings : IEquatable + { + /// + /// Requires TLS be enabled for all incomming requets (or loopback adapter) + /// + public readonly bool DisabledTlsRequired { get; init; } + + /// + /// Checks that sessions are enabled for incomming requests + /// and that they are not new sessions. + /// + public readonly bool DisableSessionsRequired { get; init; } + + /// + /// Allows connections that define cross-site sec headers + /// to be processed or denied (denied by default) + /// + public readonly bool DisableCrossSiteDenied { get; init; } + + /// + /// Enables referr match protection. Requires that if a referer header is + /// set that it matches the current origin + /// + public readonly bool DisableRefererMatch { get; init; } + + /// + /// Requires all connections to have pass an IsBrowser() check + /// (requires a valid user-agent header that contains Mozilla in + /// the string) + /// + public readonly bool DisableBrowsersOnly { get; init; } + + /// + /// If the connection has a valid session, verifies that the + /// stored session origin matches the client's origin header. + /// (confirms the session is coming from the same origin it + /// was created on) + /// + public readonly bool DisableVerifySessionCors { get; init; } + + /// + /// Disables response caching, by setting the cache control headers appropriatly. + /// Default is disabled + /// + public readonly bool EnableCaching { get; init; } + + + /// + public override bool Equals(object obj) => obj is ProtectionSettings settings && Equals(settings); + /// + public override int GetHashCode() => base.GetHashCode(); + + /// + public static bool operator ==(ProtectionSettings left, ProtectionSettings right) => left.Equals(right); + /// + public static bool operator !=(ProtectionSettings left, ProtectionSettings right) => !(left == right); + + /// + public bool Equals(ProtectionSettings other) + { + return DisabledTlsRequired == other.DisabledTlsRequired && + DisableSessionsRequired == other.DisableSessionsRequired && + DisableCrossSiteDenied == other.DisableCrossSiteDenied && + DisableRefererMatch == other.DisableRefererMatch && + DisableBrowsersOnly == other.DisableBrowsersOnly && + DisableVerifySessionCors == other.DisableVerifySessionCors && + EnableCaching == other.EnableCaching; + } + } +} diff --git a/lib/Plugins.Essentials/src/Endpoints/ResourceEndpointBase.cs b/lib/Plugins.Essentials/src/Endpoints/ResourceEndpointBase.cs new file mode 100644 index 0000000..4af3c30 --- /dev/null +++ b/lib/Plugins.Essentials/src/Endpoints/ResourceEndpointBase.cs @@ -0,0 +1,346 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: ResourceEndpointBase.cs +* +* ResourceEndpointBase.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 System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; + +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials.Extensions; + +namespace VNLib.Plugins.Essentials.Endpoints +{ + + /// + /// Provides a base class for implementing un-authenticated resource endpoints + /// with basic (configurable) security checks + /// + public abstract class ResourceEndpointBase : VirtualEndpoint + { + /// + /// Default protection settings. Protection settings are the most + /// secure by default, should be loosened an necessary + /// + protected virtual ProtectionSettings EndpointProtectionSettings { get; } + + /// + public override async ValueTask Process(HttpEntity entity) + { + try + { + ERRNO preProc = PreProccess(entity); + if (preProc == ERRNO.E_FAIL) + { + return VfReturnType.Forbidden; + } + //Entity was responded to by the pre-processor + if (preProc < 0) + { + return VfReturnType.VirtualSkip; + } + //If websockets are quested allow them to be processed in a logged-in/secure context + if (entity.Server.IsWebSocketRequest) + { + return await WebsocketRequestedAsync(entity); + } + ValueTask op = entity.Server.Method switch + { + //Get request to get account + HttpMethod.GET => GetAsync(entity), + HttpMethod.POST => PostAsync(entity), + HttpMethod.DELETE => DeleteAsync(entity), + HttpMethod.PUT => PutAsync(entity), + HttpMethod.PATCH => PatchAsync(entity), + HttpMethod.OPTIONS => OptionsAsync(entity), + _ => AlternateMethodAsync(entity, entity.Server.Method), + }; + return await op; + } + catch (InvalidJsonRequestException ije) + { + //Write the je to debug log + Log.Debug(ije, "Failed to de-serialize a request entity body"); + //If the method is not POST/PUT/PATCH return a json message + if ((entity.Server.Method & (HttpMethod.HEAD | HttpMethod.OPTIONS | HttpMethod.TRACE | HttpMethod.DELETE)) > 0) + { + return VfReturnType.BadRequest; + } + //Only allow if json is an accepted response type + if (!entity.Server.Accepts(ContentType.Json)) + { + return VfReturnType.BadRequest; + } + //Build web-message + WebMessage webm = new() + { + Result = "Request body is not valid json" + }; + //Set the response webm + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + //return virtual + return VfReturnType.VirtualSkip; + } + catch (TerminateConnectionException) + { + //A TC exception is intentional and should always propagate to the runtime + throw; + } + catch (ContentTypeUnacceptableException) + { + /* + * The runtime will handle a 406 unaccetptable response + * and invoke the proper error app handler + */ + throw; + } + //Re-throw exceptions that are cause by reading the transport layer + catch (IOException ioe) when (ioe.InnerException is SocketException) + { + throw; + } + catch (Exception ex) + { + //Log an uncaught excetpion and return an error code (log may not be initialized) + Log?.Error(ex); + return VfReturnType.Error; + } + } + + /// + /// Allows for synchronous Pre-Processing of an entity. The result + /// will determine if the method processing methods will be invoked, or + /// a error code will be returned + /// + /// The incomming request to process + /// + /// True if processing should continue, false if the response should be + /// , less than 0 if entity was + /// responded to. + /// + protected virtual ERRNO PreProccess(HttpEntity entity) + { + //Disable cache if requested + if (!EndpointProtectionSettings.EnableCaching) + { + entity.Server.SetNoCache(); + } + + //Enforce TLS + if (!EndpointProtectionSettings.DisabledTlsRequired && !entity.IsSecure && !entity.IsLocalConnection) + { + return false; + } + + //Enforce browser check + if (!EndpointProtectionSettings.DisableBrowsersOnly && !entity.Server.IsBrowser()) + { + return false; + } + + //Enforce refer check + if (!EndpointProtectionSettings.DisableRefererMatch && entity.Server.Referer != null && !entity.Server.RefererMatch()) + { + return false; + } + + //enforce session basic + if (!EndpointProtectionSettings.DisableSessionsRequired && (!entity.Session.IsSet || entity.Session.IsNew)) + { + return false; + } + + /* + * If sessions are required, verify cors is set, and the client supplied an origin header, + * verify that it matches the origin that was specified during session initialization + */ + if ((!EndpointProtectionSettings.DisableSessionsRequired & !EndpointProtectionSettings.DisableVerifySessionCors) && entity.Server.Origin != null && !entity.Session.CrossOriginMatch) + { + return false; + } + + //Enforce cross-site + if (!EndpointProtectionSettings.DisableCrossSiteDenied && entity.Server.IsCrossSite()) + { + return false; + } + + return true; + } + + /// + /// This method gets invoked when an incoming POST request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual ValueTask PostAsync(HttpEntity entity) + { + return ValueTask.FromResult(Post(entity)); + } + /// + /// This method gets invoked when an incoming GET request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual ValueTask GetAsync(HttpEntity entity) + { + return ValueTask.FromResult(Get(entity)); + } + /// + /// This method gets invoked when an incoming DELETE request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual ValueTask DeleteAsync(HttpEntity entity) + { + return ValueTask.FromResult(Delete(entity)); + } + /// + /// This method gets invoked when an incoming PUT request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual ValueTask PutAsync(HttpEntity entity) + { + return ValueTask.FromResult(Put(entity)); + } + /// + /// This method gets invoked when an incoming PATCH request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual ValueTask PatchAsync(HttpEntity entity) + { + return ValueTask.FromResult(Patch(entity)); + } + + protected virtual ValueTask OptionsAsync(HttpEntity entity) + { + return ValueTask.FromResult(Options(entity)); + } + + /// + /// Invoked when a request is received for a method other than GET, POST, DELETE, or PUT; + /// + /// The entity that + /// The request method + /// The results of the processing + protected virtual ValueTask AlternateMethodAsync(HttpEntity entity, HttpMethod method) + { + return ValueTask.FromResult(AlternateMethod(entity, method)); + } + + /// + /// Invoked when the current endpoint received a websocket request + /// + /// The entity that requested the websocket + /// The results of the operation + protected virtual ValueTask WebsocketRequestedAsync(HttpEntity entity) + { + return ValueTask.FromResult(WebsocketRequested(entity)); + } + + /// + /// This method gets invoked when an incoming POST request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual VfReturnType Post(HttpEntity entity) + { + //Return method not allowed + entity.CloseResponse(HttpStatusCode.MethodNotAllowed); + return VfReturnType.VirtualSkip; + } + /// + /// This method gets invoked when an incoming GET request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual VfReturnType Get(HttpEntity entity) + { + return VfReturnType.ProcessAsFile; + } + /// + /// This method gets invoked when an incoming DELETE request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual VfReturnType Delete(HttpEntity entity) + { + entity.CloseResponse(HttpStatusCode.MethodNotAllowed); + return VfReturnType.VirtualSkip; + } + /// + /// This method gets invoked when an incoming PUT request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual VfReturnType Put(HttpEntity entity) + { + entity.CloseResponse(HttpStatusCode.MethodNotAllowed); + return VfReturnType.VirtualSkip; + } + /// + /// This method gets invoked when an incoming PATCH request to the endpoint has been requested. + /// + /// The entity to be processed + /// The result of the operation to return to the file processor + protected virtual VfReturnType Patch(HttpEntity entity) + { + entity.CloseResponse(HttpStatusCode.MethodNotAllowed); + return VfReturnType.VirtualSkip; + } + /// + /// Invoked when a request is received for a method other than GET, POST, DELETE, or PUT; + /// + /// The entity that + /// The request method + /// The results of the processing + protected virtual VfReturnType AlternateMethod(HttpEntity entity, HttpMethod method) + { + //Return method not allowed + entity.CloseResponse(HttpStatusCode.MethodNotAllowed); + return VfReturnType.VirtualSkip; + } + + protected virtual VfReturnType Options(HttpEntity entity) + { + return VfReturnType.Forbidden; + } + + /// + /// Invoked when the current endpoint received a websocket request + /// + /// The entity that requested the websocket + /// The results of the operation + protected virtual VfReturnType WebsocketRequested(HttpEntity entity) + { + entity.CloseResponse(HttpStatusCode.Forbidden); + return VfReturnType.VirtualSkip; + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Endpoints/UnprotectedWebEndpoint.cs b/lib/Plugins.Essentials/src/Endpoints/UnprotectedWebEndpoint.cs new file mode 100644 index 0000000..cc923c7 --- /dev/null +++ b/lib/Plugins.Essentials/src/Endpoints/UnprotectedWebEndpoint.cs @@ -0,0 +1,42 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: UnprotectedWebEndpoint.cs +* +* UnprotectedWebEndpoint.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 VNLib.Utils; + +namespace VNLib.Plugins.Essentials.Endpoints +{ + /// + /// A base class for un-authenticated web (browser) based resource endpoints + /// to implement. Adds additional security checks + /// + public abstract class UnprotectedWebEndpoint : ResourceEndpointBase + { + /// + protected override ERRNO PreProccess(HttpEntity entity) + { + return base.PreProccess(entity) + && (!entity.Session.IsSet || entity.Session.SessionType == Sessions.SessionType.Web); + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Endpoints/VirtualEndpoint.cs b/lib/Plugins.Essentials/src/Endpoints/VirtualEndpoint.cs new file mode 100644 index 0000000..5beb4b9 --- /dev/null +++ b/lib/Plugins.Essentials/src/Endpoints/VirtualEndpoint.cs @@ -0,0 +1,67 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: VirtualEndpoint.cs +* +* VirtualEndpoint.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 System.Threading.Tasks; + +using VNLib.Utils.Logging; + +namespace VNLib.Plugins.Essentials.Endpoints +{ + /// + /// Provides a base class for entity processors + /// with checks and a log provider + /// + /// The entity type to process + public abstract class VirtualEndpoint : MarshalByRefObject, IVirtualEndpoint + { + /// + public virtual string Path { get; protected set; } + + /// + /// An to write logs to + /// + protected ILogProvider Log { get; private set; } + + /// + /// Sets the log and path and checks the values + /// + /// The path this instance represents + /// The log provider that will be used + /// + protected void InitPathAndLog(string Path, ILogProvider log) + { + if (string.IsNullOrWhiteSpace(Path) || Path[0] != '/') + { + throw new ArgumentException("Path must begin with a '/' character", nameof(Path)); + } + //Store path + this.Path = Path; + //Store log + Log = log ?? throw new ArgumentNullException(nameof(log)); + } + /// + public abstract ValueTask Process(T entity); + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/EventProcessor.cs b/lib/Plugins.Essentials/src/EventProcessor.cs new file mode 100644 index 0000000..4f51907 --- /dev/null +++ b/lib/Plugins.Essentials/src/EventProcessor.cs @@ -0,0 +1,727 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: EventProcessor.cs +* +* EventProcessor.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 System.IO; +using System.Net; +using System.Linq; +using System.Threading; +using System.Net.Sockets; +using System.Threading.Tasks; +using System.Collections.Generic; + +using VNLib.Net.Http; +using VNLib.Utils.IO; +using VNLib.Utils.Logging; +using VNLib.Utils.Resources; +using VNLib.Plugins.Essentials.Content; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Essentials.Extensions; + +#nullable enable + +namespace VNLib.Plugins.Essentials +{ + + /// + /// Provides an abstract base implementation of + /// that breaks down simple processing procedures, routing, and session + /// loading. + /// + public abstract class EventProcessor : IWebRoot + { + private static readonly AsyncLocal _currentProcessor = new(); + + /// + /// Gets the current (ambient) async local event processor + /// + public static EventProcessor? Current => _currentProcessor.Value; + + /// + /// The filesystem entrypoint path for the site + /// + public abstract string Directory { get; } + /// + public abstract string Hostname { get; } + + /// + /// Gets the EP processing options + /// + public abstract IEpProcessingOptions Options { get; } + + /// + /// Event log provider + /// + protected abstract ILogProvider Log { get; } + + /// + /// + /// Called when the server intends to process a file and requires translation from a + /// uri path to a usable filesystem path + /// + /// + /// NOTE: This function must be thread-safe! + /// + /// + /// The path requested by the request + /// The translated and filtered filesystem path used to identify the file resource + public abstract string TranslateResourcePath(string requestPath); + /// + /// + /// When an error occurs and is handled by the library, this event is invoked + /// + /// + /// NOTE: This function must be thread-safe! + /// + /// + /// The error code that was created during processing + /// The active IHttpEvent representing the faulted request + /// A value indicating if the entity was proccsed by this call + public abstract bool ErrorHandler(HttpStatusCode errorCode, IHttpEvent entity); + /// + /// For pre-processing a request entity before all endpoint lookups are performed + /// + /// The http entity to process + /// The results to return to the file processor, or null of the entity requires further processing + public abstract ValueTask PreProcessEntityAsync(HttpEntity entity); + /// + /// Allows for post processing of a selected for the given entity + /// + /// The http entity to process + /// The selected file processing routine for the given request + public abstract void PostProcessFile(HttpEntity entity, in FileProcessArgs chosenRoutine); + + #region redirects + /// + public IReadOnlyDictionary Redirects => _redirects; + + private Dictionary _redirects = new(); + + /// + /// Initializes 301 redirects table from a collection of redirects + /// + /// A collection of redirects + public void SetRedirects(IEnumerable redirs) + { + //To dictionary + Dictionary r = redirs.ToDictionary(r => r.Url, r => r, StringComparer.OrdinalIgnoreCase); + //Swap + _ = Interlocked.Exchange(ref _redirects, r); + } + + #endregion + + #region sessions + + /// + /// An that connects stateful sessions to + /// HTTP connections + /// + private ISessionProvider? Sessions; + + /// + /// Sets or resets the current + /// for all connections + /// + /// The new + public void SetSessionProvider(ISessionProvider? sp) => _ = Interlocked.Exchange(ref Sessions, sp); + + #endregion + + #region router + + /// + /// An to route files to be processed + /// + private IPageRouter? Router; + + /// + /// Sets or resets the current + /// for all connections + /// + /// to route incomming connections + public void SetPageRouter(IPageRouter? router) => _ = Interlocked.Exchange(ref Router, router); + + #endregion + + #region Virtual Endpoints + + /* + * Wrapper class for converting IHttpEvent endpoints to + * httpEntityEndpoints + */ + private sealed class EvEndpointWrapper : IVirtualEndpoint + { + private readonly IVirtualEndpoint _wrapped; + public EvEndpointWrapper(IVirtualEndpoint wrapping) => _wrapped = wrapping; + string IEndpoint.Path => _wrapped.Path; + ValueTask IVirtualEndpoint.Process(HttpEntity entity) => _wrapped.Process(entity); + } + + + /* + * The VE table is read-only for the processor and my only + * be updated by the application via the methods below + * + * Since it would be very inefficient to track endpoint users + * using locks, we can assume any endpoint that is currently + * processing requests cannot be stopped, so we just focus on + * swapping the table when updates need to be made. + * + * This means calls to modify the table will read the table + * (clone it), modify the local copy, then exhange it for + * the active table so new requests will be processed on the + * new table. + * + * To make the calls to modify the table thread safe, a lock is + * held while modification operations run, then the updated + * copy is published. Any threads reading the old table + * will continue to use a stale endpoint. + */ + + /// + /// A "lookup table" that represents virtual endpoints to be processed when an + /// incomming connection matches its path parameter + /// + private Dictionary> VirtualEndpoints = new(); + + + /* + * A lock that is held by callers that intend to + * modify the vep table at the same time + */ + private readonly object VeUpdateLock = new(); + + + /// + /// Determines the endpoint type(s) and adds them to the endpoint store(s) as necessary + /// + /// Params array of endpoints to add to the store + /// + /// + public void AddEndpoint(params IEndpoint[] endpoints) + { + //Check + _ = endpoints ?? throw new ArgumentNullException(nameof(endpoints)); + //Make sure all endpoints specify a path + if(endpoints.Any(static e => string.IsNullOrWhiteSpace(e?.Path))) + { + throw new ArgumentException("Endpoints array contains one or more empty endpoints"); + } + + if (endpoints.Length == 0) + { + return; + } + + //Get virtual endpoints + IEnumerable> eps = endpoints + .Where(static e => e is IVirtualEndpoint) + .Select(static e => (IVirtualEndpoint)e); + + //Get http event endpoints and create wrapper classes for conversion + IEnumerable> evs = endpoints + .Where(static e => e is IVirtualEndpoint) + .Select(static e => new EvEndpointWrapper((e as IVirtualEndpoint)!)); + + //Uinion endpoints by their paths to combine them + IEnumerable> allEndpoints = eps.UnionBy(evs, static s => s.Path); + + lock (VeUpdateLock) + { + //Clone the current dictonary + Dictionary> newTable = new(VirtualEndpoints, StringComparer.OrdinalIgnoreCase); + //Insert the new eps, and/or overwrite old eps + foreach(IVirtualEndpoint ep in allEndpoints) + { + newTable.Add(ep.Path, ep); + } + + //Store the new table + _ = Interlocked.Exchange(ref VirtualEndpoints, newTable); + } + } + + /// + /// Removes the specified endpoint from the virtual store and oauthendpoints if eneabled and found + /// + /// A collection of endpoints to remove from the table + public void RemoveEndpoint(params IEndpoint[] eps) + { + _ = eps ?? throw new ArgumentNullException(nameof(eps)); + //Call remove on path + RemoveVirtualEndpoint(eps.Select(static s => s.Path).ToArray()); + } + + /// + /// Stops listening for connections to the specified identified by its path + /// + /// An array of endpoint paths to remove from the table + /// + /// + /// + public void RemoveVirtualEndpoint(params string[] paths) + { + _ = paths ?? throw new ArgumentNullException(nameof(paths)); + //Make sure all endpoints specify a path + if (paths.Any(static e => string.IsNullOrWhiteSpace(e))) + { + throw new ArgumentException("Paths array contains one or more empty strings"); + } + + if(paths.Length == 0) + { + return; + } + + //take update lock + lock (VeUpdateLock) + { + //Clone the current dictonary + Dictionary> newTable = new(VirtualEndpoints, StringComparer.OrdinalIgnoreCase); + + foreach(string eps in paths) + { + _ = newTable.Remove(eps); + } + //Store the new table ony if the endpoint existed + _ = Interlocked.Exchange(ref VirtualEndpoints, newTable); + } + } + + #endregion + + /// + public virtual async ValueTask ClientConnectedAsync(IHttpEvent httpEvent) + { + //load ref to session provider + ISessionProvider? _sessions = Sessions; + + //Set ambient processor context + _currentProcessor.Value = this; + //Start cancellation token + CancellationTokenSource timeout = new(Options.ExecutionTimeout); + try + { + //Session handle, default to the shared empty session + SessionHandle sessionHandle = default; + + //If sessions are set, get a session for the current connection + if (_sessions != null) + { + //Get the session + sessionHandle = await _sessions.GetSessionAsync(httpEvent, timeout.Token); + //If the processor had an error recovering the session, return the result to the processor + if (sessionHandle.EntityStatus != FileProcessArgs.Continue) + { + ProcessFile(httpEvent, sessionHandle.EntityStatus); + return; + } + } + try + { + //Setup entity + HttpEntity entity = new(httpEvent, this, in sessionHandle, timeout.Token); + //Pre-process entity + FileProcessArgs preProc = await PreProcessEntityAsync(entity); + //If preprocess returned a value, exit + if (preProc != FileProcessArgs.Continue) + { + ProcessFile(httpEvent, in preProc); + return; + } + + if (VirtualEndpoints.Count > 0) + { + //Process a virtual file + FileProcessArgs virtualArgs = await ProcessVirtualAsync(entity); + //If the entity was processed, exit + if (virtualArgs != FileProcessArgs.Continue) + { + ProcessFile(httpEvent, in virtualArgs); + return; + } + } + //If no virtual processor handled the ws request, deny it + if (entity.Server.IsWebSocketRequest) + { + ProcessFile(httpEvent, in FileProcessArgs.Deny); + return; + } + //Finally process as file + FileProcessArgs args = await RouteFileAsync(entity); + //Finally process the file + ProcessFile(httpEvent, in args); + } + finally + { + //Capture all session release exceptions + try + { + //Release the session + await sessionHandle.ReleaseAsync(httpEvent); + } + catch (Exception ex) + { + Log.Error(ex, "Exception raised while releasing the assocated session"); + } + } + } + catch (ContentTypeUnacceptableException) + { + /* + * The user application attempted to set a content that the client does not accept + * Assuming this exception was uncaught by application code, there should not be + * any response body, either way we should respond with the unacceptable status code + */ + CloseWithError(HttpStatusCode.NotAcceptable, httpEvent); + } + catch (TerminateConnectionException) + { + throw; + } + catch (ResourceUpdateFailedException ruf) + { + Log.Warn(ruf); + CloseWithError(HttpStatusCode.ServiceUnavailable, httpEvent); + } + catch (SessionException se) + { + Log.Warn(se, "An exception was raised while attempting to get or save a session"); + CloseWithError(HttpStatusCode.ServiceUnavailable, httpEvent); + return; + } + catch (OperationCanceledException oce) + { + Log.Warn(oce, "Request execution time exceeded, connection terminated"); + CloseWithError(HttpStatusCode.ServiceUnavailable, httpEvent); + } + catch (IOException ioe) when (ioe.InnerException is SocketException) + { + throw; + } + catch (Exception ex) + { + Log.Warn(ex, "Unhandled exception during application code execution."); + //Invoke the root error handler + CloseWithError(HttpStatusCode.InternalServerError, httpEvent); + } + finally + { + timeout.Dispose(); + _currentProcessor.Value = null; + } + } + + /// + /// Accepts the entity to process a file for an the selected + /// by user code and determines what file-system file to open and respond to the connection with. + /// + /// The entity to process the file for + /// The selected to determine what file to process + protected virtual void ProcessFile(IHttpEvent entity, in FileProcessArgs args) + { + try + { + string? filename = null; + //Switch on routine + switch (args.Routine) + { + //Close the connection with an error state + case FpRoutine.Error: + CloseWithError(HttpStatusCode.InternalServerError, entity); + return; + //Redirect the user + case FpRoutine.Redirect: + //Set status code + entity.Redirect(RedirectType.Found, args.Alternate); + return; + //Deny + case FpRoutine.Deny: + CloseWithError(HttpStatusCode.Forbidden, entity); + return; + //Not return not found + case FpRoutine.NotFound: + CloseWithError(HttpStatusCode.NotFound, entity); + return; + //Serve other file + case FpRoutine.ServeOther: + { + //Use the specified relative alternate path the user specified + if (FindResourceInRoot(args.Alternate, out string otherPath)) + { + filename = otherPath; + } + } + break; + //Normal file lookup + case FpRoutine.Continue: + { + //Lookup the file based on the client requested local path + if (FindResourceInRoot(entity.Server.Path, out string path)) + { + filename = path; + } + } + break; + //The user indicated that the file is a fully qualified path, and should be treated directly + case FpRoutine.ServeOtherFQ: + { + //Get the absolute path of the file rooted in the current server root and determine if it exists + if (FindResourceInRoot(args.Alternate, true, out string fqPath)) + { + filename = fqPath; + } + } + break; + //The user has indicated they handled all necessary action, and we will exit + case FpRoutine.VirtualSkip: + return; + default: + break; + } + //If the file was not set or the request method is not a GET (or HEAD), return not-found + if (filename == null || (entity.Server.Method & (HttpMethod.GET | HttpMethod.HEAD)) == 0) + { + CloseWithError(HttpStatusCode.NotFound, entity); + return; + } + DateTime fileLastModified = File.GetLastWriteTimeUtc(filename); + //See if the last modifed header was set + DateTimeOffset? ifModifedSince = entity.Server.LastModified(); + //If the header was set, check the date, if the file has been modified since, continue sending the file + if (ifModifedSince != null && ifModifedSince.Value > fileLastModified) + { + //File has not been modified + entity.CloseResponse(HttpStatusCode.NotModified); + return; + } + //Get the content type of he file + ContentType fileType = HttpHelpers.GetContentTypeFromFile(filename); + //Make sure the client accepts the content type + if (entity.Server.Accepts(fileType)) + { + //set last modified time as the files last write time + entity.Server.LastModified(fileLastModified); + //try to open the selected file for reading and allow sharing + FileStream fs = new (filename, FileMode.Open, FileAccess.Read, FileShare.Read); + //Check for range + if (entity.Server.Range != null && entity.Server.Range.Item1 > 0) + { + //Seek the stream to the specified position + fs.Position = entity.Server.Range.Item1; + entity.CloseResponse(HttpStatusCode.PartialContent, fileType, fs); + } + else + { + //send the whole file + entity.CloseResponse(HttpStatusCode.OK, fileType, fs); + } + return; + } + else + { + //Unacceptable + CloseWithError(HttpStatusCode.NotAcceptable, entity); + return; + } + } + catch (IOException ioe) + { + Log.Information(ioe, "Unhandled exception during file opening."); + CloseWithError(HttpStatusCode.Locked, entity); + return; + } + catch (Exception ex) + { + Log.Information(ex, "Unhandled exception during file opening."); + //Invoke the root error handler + CloseWithError(HttpStatusCode.InternalServerError, entity); + return; + } + } + + private void CloseWithError(HttpStatusCode code, IHttpEvent entity) + { + //Invoke the inherited error handler + if (!ErrorHandler(code, entity)) + { + //Disable cache + entity.Server.SetNoCache(); + //Error handler does not have a response for the error code, so return a generic error code + entity.CloseResponse(code); + } + } + + + /// + /// If virtual endpoints are enabled, checks for the existance of an + /// endpoint and attmepts to process that endpoint. + /// + /// The http entity to proccess + /// The results to return to the file processor, or null of the entity requires further processing + protected virtual async ValueTask ProcessVirtualAsync(HttpEntity entity) + { + //See if the virtual file is servicable + if (!VirtualEndpoints.TryGetValue(entity.Server.Path, out IVirtualEndpoint? vf)) + { + return FileProcessArgs.Continue; + } + + //Invoke the page handler process method + VfReturnType rt = await vf.Process(entity); + + if (rt == VfReturnType.VirtualSkip) + { + //Virtual file was handled by the handler + return FileProcessArgs.VirtualSkip; + } + else if(rt == VfReturnType.ProcessAsFile) + { + return FileProcessArgs.Continue; + } + + //If not a get request, process it directly + if (entity.Server.Method == HttpMethod.GET) + { + switch (rt) + { + case VfReturnType.Forbidden: + return FileProcessArgs.Deny; + case VfReturnType.NotFound: + return FileProcessArgs.NotFound; + case VfReturnType.Error: + return FileProcessArgs.Error; + default: + break; + } + } + + switch (rt) + { + case VfReturnType.Forbidden: + entity.CloseResponse(HttpStatusCode.Forbidden); + break; + case VfReturnType.BadRequest: + entity.CloseResponse(HttpStatusCode.BadRequest); + break; + case VfReturnType.Error: + entity.CloseResponse(HttpStatusCode.InternalServerError); + break; + case VfReturnType.NotFound: + default: + entity.CloseResponse(HttpStatusCode.NotFound); + break; + } + + return FileProcessArgs.VirtualSkip; + } + + /// + /// Determines the best processing response for the given connection. + /// Alternativley may respond to the entity directly. + /// + /// The http entity to process + /// The results to return to the file processor, this method must return an argument + protected virtual async ValueTask RouteFileAsync(HttpEntity entity) + { + //Read local copy of the router + + IPageRouter? router = Router; + //Make sure router is set + if (router == null) + { + return FileProcessArgs.Continue; + } + //Get a file routine + FileProcessArgs routine = await router.RouteAsync(entity); + //Call post processor method + PostProcessFile(entity, in routine); + //Return the routine + return routine; + } + + + /// + /// 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 + /// + public bool FindResourceInRoot(string resourcePath, bool fullyQualified, out string path) + { + //Special case where user's can specify a fullly qualified path (meant to reach a remote file, eg UNC/network share or other disk) + if (fullyQualified && Path.IsPathRooted(resourcePath) && Path.IsPathFullyQualified(resourcePath) && FileOperations.FileExists(resourcePath)) + { + path = resourcePath; + return true; + } + //Otherwise invoke non fully qualified path + return FindResourceInRoot(resourcePath, out path); + } + + /// + /// Determines if a requested resource exists within the and is allowed to be accessed. + /// + /// The path to the resource + /// An out parameter that is set to the absolute path to the existing and accessable resource + /// True if the resource exists and is allowed to be accessed + public bool FindResourceInRoot(string resourcePath, out string path) + { + //Check after fully qualified path name because above is a special case + path = TranslateResourcePath(resourcePath); + string extension = Path.GetExtension(path); + //Make sure extension isnt blocked + if (Options.ExcludedExtensions.Contains(extension)) + { + return false; + } + //Trailing / means dir, so look for a default file (index.html etc) (most likely so check first?) + if (Path.EndsInDirectorySeparator(path)) + { + string comp = path; + //Find default file if blank + foreach (string d in Options.DefaultFiles) + { + path = Path.Combine(comp, d); + if (FileOperations.FileExists(path)) + { + //Get attributes + FileAttributes att = FileOperations.GetAttributes(path); + //Make sure the file is accessable and isnt an unsafe file + return ((att & Options.AllowedAttributes) > 0) && ((att & Options.DissallowedAttributes) == 0); + } + } + } + //try the file as is + else if (FileOperations.FileExists(path)) + { + //Get attributes + FileAttributes att = FileOperations.GetAttributes(path); + //Make sure the file is accessable and isnt an unsafe file + return ((att & Options.AllowedAttributes) > 0) && ((att & Options.DissallowedAttributes) == 0); + } + return false; + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/CollectionsExtensions.cs b/lib/Plugins.Essentials/src/Extensions/CollectionsExtensions.cs new file mode 100644 index 0000000..9500d5e --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/CollectionsExtensions.cs @@ -0,0 +1,85 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: CollectionsExtensions.cs +* +* CollectionsExtensions.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 System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + /// + /// + /// + public static class CollectionsExtensions + { + /// + /// Gets a value by the specified key if it exsits and the value is not null/empty + /// + /// + /// Key associated with the value + /// Value associated with the key + /// True of the key is found and is not noll/empty, false otherwise + public static bool TryGetNonEmptyValue(this IReadOnlyDictionary dict, string key, [MaybeNullWhen(false)] out string value) + { + if (dict.TryGetValue(key, out string? val) && !string.IsNullOrWhiteSpace(val)) + { + value = val; + return true; + } + value = null; + return false; + } + /// + /// Determines if an argument was set in a by comparing + /// the value stored at the key, to the type argument + /// + /// + /// The argument's key + /// The argument to compare against + /// + /// True if the key was found, and the value at the key is equal to the type parameter. False if the key is null/empty, or the + /// value does not match the specified type + /// + /// + public static bool IsArgumentSet(this IReadOnlyDictionary dict, string key, ReadOnlySpan argument) + { + //Try to get the value from the dict, if the value is null casting it to span (implicitly) should stop null excpetions and return false + return dict.TryGetValue(key, out string? value) && string.GetHashCode(argument) == string.GetHashCode(value); + } + /// + /// + /// + /// + /// + /// + /// + /// + public static TValue? GetValueOrDefault(this IDictionary dict, TKey key) + { + return dict.TryGetValue(key, out TValue? value) ? value : default; + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/ConnectionInfoExtensions.cs b/lib/Plugins.Essentials/src/Extensions/ConnectionInfoExtensions.cs new file mode 100644 index 0000000..ba01132 --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/ConnectionInfoExtensions.cs @@ -0,0 +1,361 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: ConnectionInfoExtensions.cs +* +* ConnectionInfoExtensions.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 System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Runtime.CompilerServices; + +using VNLib.Net.Http; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + /// + /// Provides extension methods + /// for common use cases + /// + public static class IConnectionInfoExtensions + { + public const string SEC_HEADER_MODE = "Sec-Fetch-Mode"; + public const string SEC_HEADER_SITE = "Sec-Fetch-Site"; + public const string SEC_HEADER_USER = "Sec-Fetch-User"; + public const string SEC_HEADER_DEST = "Sec-Fetch-Dest"; + public const string X_FORWARDED_FOR_HEADER = "x-forwarded-for"; + public const string X_FORWARDED_PROTO_HEADER = "x-forwarded-proto"; + public const string DNT_HEADER = "dnt"; + + /// + /// Cache-Control header value for disabling cache + /// + public static readonly string NO_CACHE_RESPONSE_HEADER_VALUE = HttpHelpers.GetCacheString(CacheType.NoCache | CacheType.NoStore | CacheType.Revalidate); + + /// + /// Gets the header value and converts its value to a datetime value + /// + /// The if modified-since header date-time, null if the header was not set or the value was invalid + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static DateTimeOffset? LastModified(this IConnectionInfo server) + { + //Get the if-modified-since header + string? ifModifiedSince = server.Headers[HttpRequestHeader.IfModifiedSince]; + //Make sure tis set and try to convert it to a date-time structure + return DateTimeOffset.TryParse(ifModifiedSince, out DateTimeOffset d) ? d : null; + } + + /// + /// Sets the last-modified response header value + /// + /// + /// Time the entity was last modified + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void LastModified(this IConnectionInfo server, DateTimeOffset value) + { + server.Headers[HttpResponseHeader.LastModified] = value.ToString("R"); + } + + /// + /// Is the connection requesting cors + /// + /// true if the user-agent specified the cors security header + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsCors(this IConnectionInfo server) => "cors".Equals(server.Headers[SEC_HEADER_MODE], StringComparison.OrdinalIgnoreCase); + /// + /// Determines if the User-Agent specified "cross-site" in the Sec-Site header, OR + /// the connection spcified an origin header and the origin's host does not match the + /// requested host + /// + /// true if the request originated from a site other than the current one + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsCrossSite(this IConnectionInfo server) + { + return "cross-site".Equals(server.Headers[SEC_HEADER_SITE], StringComparison.OrdinalIgnoreCase) + || (server.Origin != null && !server.RequestUri.DnsSafeHost.Equals(server.Origin.DnsSafeHost, StringComparison.Ordinal)); + } + /// + /// Is the connection user-agent created, or automatic + /// + /// + /// true if sec-user header was set to "?1" + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsUserInvoked(this IConnectionInfo server) => "?1".Equals(server.Headers[SEC_HEADER_USER], StringComparison.OrdinalIgnoreCase); + /// + /// Was this request created from normal user navigation + /// + /// true if sec-mode set to "navigate" + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNavigation(this IConnectionInfo server) => "navigate".Equals(server.Headers[SEC_HEADER_MODE], StringComparison.OrdinalIgnoreCase); + /// + /// Determines if the client specified "no-cache" for the cache control header, signalling they do not wish to cache the entity + /// + /// True if contains the string "no-cache", false otherwise + public static bool NoCache(this IConnectionInfo server) + { + string? cache_header = server.Headers[HttpRequestHeader.CacheControl]; + return !string.IsNullOrWhiteSpace(cache_header) && cache_header.Contains("no-cache", StringComparison.OrdinalIgnoreCase); + } + /// + /// Sets the response cache headers to match the requested caching type. Does not check against request headers + /// + /// + /// One or more flags that identify the way the entity can be cached + /// The max age the entity is valid for + public static void SetCache(this IConnectionInfo server, CacheType type, TimeSpan maxAge) + { + //If no cache flag is set, set the pragma header to no-cache + if((type & CacheType.NoCache) > 0) + { + server.Headers[HttpResponseHeader.Pragma] = "no-cache"; + } + //Set the cache hader string using the http helper class + server.Headers[HttpResponseHeader.CacheControl] = HttpHelpers.GetCacheString(type, maxAge); + } + /// + /// Sets the Cache-Control response header to + /// and the pragma response header to 'no-cache' + /// + /// + public static void SetNoCache(this IConnectionInfo server) + { + //Set default nocache string + server.Headers[HttpResponseHeader.CacheControl] = NO_CACHE_RESPONSE_HEADER_VALUE; + server.Headers[HttpResponseHeader.Pragma] = "no-cache"; + } + + /// + /// Gets a value indicating whether the port number in the request is equivalent to the port number + /// on the local server. + /// + /// True if the port number in the matches the + /// port false if they do not match + /// + /// + /// Users should call this method to help prevent port based attacks if your + /// code relies on the port number of the + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool EnpointPortsMatch(this IConnectionInfo server) + { + return server.RequestUri.Port == server.LocalEndpoint.Port; + } + /// + /// Determines if the host of the current request URI matches the referer header host + /// + /// True if the request host and the referer host paremeters match, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool RefererMatch(this IConnectionInfo server) + { + return server.RequestUri.DnsSafeHost.Equals(server.Referer?.DnsSafeHost, StringComparison.OrdinalIgnoreCase); + } + /// + /// Expires a client's cookie + /// + /// + /// + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ExpireCookie(this IConnectionInfo server, string name, string domain = "", string path = "/", CookieSameSite sameSite = CookieSameSite.None, bool secure = false) + { + server.SetCookie(name, string.Empty, domain, path, TimeSpan.Zero, sameSite, false, secure); + } + /// + /// Sets a cookie with an infinite (session life-span) + /// + /// + /// + /// + /// + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetSessionCookie( + this IConnectionInfo server, + string name, + string value, + string domain = "", + string path = "/", + CookieSameSite sameSite = CookieSameSite.None, + bool httpOnly = false, + bool secure = false) + { + server.SetCookie(name, value, domain, path, TimeSpan.MaxValue, sameSite, httpOnly, secure); + } + + /// + /// Sets a cookie with an infinite (session life-span) + /// + /// + /// + /// + /// + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetCookie( + this IConnectionInfo server, + string name, + string value, + TimeSpan expires, + string domain = "", + string path = "/", + CookieSameSite sameSite = CookieSameSite.None, + bool httpOnly = false, + bool secure = false) + { + server.SetCookie(name, value, domain, path, expires, sameSite, httpOnly, secure); + } + + /// + /// Is the current connection a "browser" ? + /// + /// + /// true if the user agent string contains "Mozilla" and does not contain "bot", false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsBrowser(this IConnectionInfo server) + { + //Get user-agent and determine if its a browser + return server.UserAgent != null && !server.UserAgent.Contains("bot", StringComparison.OrdinalIgnoreCase) && server.UserAgent.Contains("Mozilla", StringComparison.OrdinalIgnoreCase); + } + /// + /// Determines if the current connection is the loopback/internal network adapter + /// + /// + /// True of the connection was made from the local machine + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsLoopBack(this IConnectionInfo server) + { + IPAddress realIp = server.GetTrustedIp(); + return IPAddress.Any.Equals(realIp) || IPAddress.Loopback.Equals(realIp); + } + + /// + /// Did the connection set the dnt header? + /// + /// true if the connection specified the dnt header, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool DNT(this IConnectionInfo server) => !string.IsNullOrWhiteSpace(server.Headers[DNT_HEADER]); + + /// + /// Determins if the current connection is behind a trusted downstream server + /// + /// + /// True if the connection came from a trusted downstream server, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsBehindDownStreamServer(this IConnectionInfo server) + { + //See if there is an ambient event processor + EventProcessor? ev = EventProcessor.Current; + //See if the connection is coming from an downstream server + return ev != null && ev.Options.DownStreamServers.Contains(server.RemoteEndpoint.Address); + } + + /// + /// Gets the real IP address of the request if behind a trusted downstream server, otherwise returns the transport remote ip address + /// + /// + /// The real ip of the connection + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IPAddress GetTrustedIp(this IConnectionInfo server) => GetTrustedIp(server, server.IsBehindDownStreamServer()); + /// + /// Gets the real IP address of the request if behind a trusted downstream server, otherwise returns the transport remote ip address + /// + /// + /// + /// The real ip of the connection + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static IPAddress GetTrustedIp(this IConnectionInfo server, bool isTrusted) + { + //If the connection is not trusted, then ignore header parsing + if (isTrusted) + { + //Nginx sets a header identifying the remote ip address so parse it + string? real_ip = server.Headers[X_FORWARDED_FOR_HEADER]; + //If the real-ip header is set, try to parse is and return the address found, otherwise return the remote ep + return !string.IsNullOrWhiteSpace(real_ip) && IPAddress.TryParse(real_ip, out IPAddress? addr) ? addr : server.RemoteEndpoint.Address; + } + else + { + return server.RemoteEndpoint.Address; + } + } + + /// + /// Gets a value that determines if the connection is using tls, locally + /// or behind a trusted downstream server that is using tls. + /// + /// + /// True if the connection is secure, false otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSecure(this IConnectionInfo server) + { + //Get value of the trusted downstream server + return IsSecure(server, server.IsBehindDownStreamServer()); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsSecure(this IConnectionInfo server, bool isTrusted) + { + //If the connection is not trusted, then ignore header parsing + if (isTrusted) + { + //Standard https protocol header + string? protocol = server.Headers[X_FORWARDED_PROTO_HEADER]; + //If the header is set and equals https then tls is being used + return string.IsNullOrWhiteSpace(protocol) ? server.IsSecure : "https".Equals(protocol, StringComparison.OrdinalIgnoreCase); + } + else + { + return server.IsSecure; + } + } + + /// + /// Was the connection made on a local network to the server? NOTE: Use with caution + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsLocalConnection(this IConnectionInfo server) => server.LocalEndpoint.Address.IsLocalSubnet(server.GetTrustedIp()); + + /// + /// Get a cookie from the current request + /// + /// + /// Name/ID of cookie + /// Is set to cookie if found, or null if not + /// True if cookie exists and was retrieved + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool GetCookie(this IConnectionInfo server, string name, [NotNullWhen(true)] out string? cookieValue) + { + //Try to get a cookie from the request + return server.RequestCookies.TryGetValue(name, out cookieValue); + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs b/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs new file mode 100644 index 0000000..9458487 --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs @@ -0,0 +1,848 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: EssentialHttpEventExtensions.cs +* +* EssentialHttpEventExtensions.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 System.IO; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +using VNLib.Net.Http; +using VNLib.Hashing; +using VNLib.Utils; +using VNLib.Utils.Extensions; +using VNLib.Utils.Memory.Caching; +using static VNLib.Plugins.Essentials.Statics; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + + /// + /// Provides extension methods for manipulating s + /// + public static class EssentialHttpEventExtensions + { + public const string BEARER_STRING = "Bearer"; + private static readonly int BEARER_LEN = BEARER_STRING.Length; + + /* + * Pooled/tlocal serializers + */ + private static ThreadLocal LocalSerializer { get; } = new(() => new(Stream.Null)); + private static IObjectRental ResponsePool { get; } = ObjectRental.Create(ResponseCtor); + private static JsonResponse ResponseCtor() => new(ResponsePool); + + #region Response Configuring + + /// + /// Attempts to serialize the JSON object (with default SR_OPTIONS) to binary and configure the response for a JSON message body + /// + /// + /// + /// The result of the connection + /// The JSON object to serialzie and send as response body + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponseJson(this IHttpEvent ev, HttpStatusCode code, T response) => CloseResponseJson(ev, code, response, SR_OPTIONS); + + /// + /// Attempts to serialize the JSON object to binary and configure the response for a JSON message body + /// + /// + /// + /// The result of the connection + /// The JSON object to serialzie and send as response body + /// to use during serialization + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponseJson(this IHttpEvent ev, HttpStatusCode code, T response, JsonSerializerOptions? options) + { + JsonResponse rbuf = ResponsePool.Rent(); + try + { + //Serialze the object on the thread local serializer + LocalSerializer.Value!.Serialize(rbuf, response, options); + + //Set the response as the buffer, + ev.CloseResponse(code, ContentType.Json, rbuf); + } + catch + { + //Return back to pool on error + ResponsePool.Return(rbuf); + throw; + } + } + + /// + /// Attempts to serialize the JSON object to binary and configure the response for a JSON message body + /// + /// + /// The result of the connection + /// The JSON object to serialzie and send as response body + /// The type to use during de-serialization + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponseJson(this IHttpEvent ev, HttpStatusCode code, object response, Type type) => CloseResponseJson(ev, code, response, type, SR_OPTIONS); + + /// + /// Attempts to serialize the JSON object to binary and configure the response for a JSON message body + /// + /// + /// The result of the connection + /// The JSON object to serialzie and send as response body + /// The type to use during de-serialization + /// to use during serialization + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponseJson(this IHttpEvent ev, HttpStatusCode code, object response, Type type, JsonSerializerOptions? options) + { + JsonResponse rbuf = ResponsePool.Rent(); + try + { + //Serialze the object on the thread local serializer + LocalSerializer.Value!.Serialize(rbuf, response, type, options); + + //Set the response as the buffer, + ev.CloseResponse(code, ContentType.Json, rbuf); + } + catch + { + //Return back to pool on error + ResponsePool.Return(rbuf); + throw; + } + } + + /// + /// Writes the data to a temporary buffer and sets it as the response + /// + /// + /// The result of the connection + /// The data to send to client + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponseJson(this IHttpEvent ev, HttpStatusCode code, JsonDocument data) + { + if(data == null) + { + ev.CloseResponse(code); + return; + } + + JsonResponse rbuf = ResponsePool.Rent(); + try + { + //Serialze the object on the thread local serializer + LocalSerializer.Value!.Serialize(rbuf, data); + + //Set the response as the buffer, + ev.CloseResponse(code, ContentType.Json, rbuf); + } + catch + { + //Return back to pool on error + ResponsePool.Return(rbuf); + throw; + } + } + + /// + /// Close as response to a client with an and serializes a as the message response + /// + /// + /// The to serialize and response to client with + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponse(this IHttpEvent ev, T webm) where T:WebMessage + { + if (webm == null) + { + ev.CloseResponse(HttpStatusCode.OK); + } + else + { + //Respond with json data + ev.CloseResponseJson(HttpStatusCode.OK, webm); + } + } + + /// + /// Close a response to a connection with a file as an attachment (set content dispostion) + /// + /// + /// Status code + /// The of the desired file to attach + /// + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponseAttachment(this IHttpEvent ev, HttpStatusCode code, FileInfo file) + { + //Close with file + ev.CloseResponse(code, file); + //Set content dispostion as attachment (only if successfull) + ev.Server.Headers["Content-Disposition"] = $"attachment; filename=\"{file.Name}\""; + } + + /// + /// Close a response to a connection with a file as an attachment (set content dispostion) + /// + /// + /// Status code + /// The of the desired file to attach + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponseAttachment(this IHttpEvent ev, HttpStatusCode code, FileStream file) + { + //Close with file + ev.CloseResponse(code, file); + //Set content dispostion as attachment (only if successfull) + ev.Server.Headers["Content-Disposition"] = $"attachment; filename=\"{file.Name}\""; + } + + /// + /// Close a response to a connection with a file as an attachment (set content dispostion) + /// + /// + /// Status code + /// The data to straem to the client as an attatcment + /// The that represents the file + /// The name of the file to attach + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponseAttachment(this IHttpEvent ev, HttpStatusCode code, ContentType ct, Stream data, string fileName) + { + //Close with file + ev.CloseResponse(code, ct, data); + //Set content dispostion as attachment (only if successfull) + ev.Server.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\""; + } + + /// + /// Close a response to a connection with a file as the entire response body (not attachment) + /// + /// + /// Status code + /// The of the desired file to attach + /// + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, FileInfo file) + { + //Open filestream for file + FileStream fs = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + try + { + //Set the input as a stream + ev.CloseResponse(code, fs); + //Set last modified time only if successfull + ev.Server.Headers[HttpResponseHeader.LastModified] = file.LastWriteTimeUtc.ToString("R"); + } + catch + { + //If their is an exception close the stream and re-throw + fs.Dispose(); + throw; + } + } + + /// + /// Close a response to a connection with a as the entire response body (not attachment) + /// + /// + /// Status code + /// The of the desired file to attach + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, FileStream file) + { + //Get content type from filename + ContentType ct = HttpHelpers.GetContentTypeFromFile(file.Name); + //Set the input as a stream + ev.CloseResponse(code, ct, file); + } + + /// + /// Close a response to a connection with a character buffer using the server wide + /// encoding + /// + /// + /// The response status code + /// The the data represents + /// The character buffer to send + /// This method will store an encoded copy as a memory stream, so be careful with large buffers + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, ContentType type, in ReadOnlySpan data) + { + //Get a memory stream using UTF8 encoding + CloseResponse(ev, code, type, in data, ev.Server.Encoding); + } + + /// + /// Close a response to a connection with a character buffer using the specified encoding type + /// + /// + /// The response status code + /// The the data represents + /// The character buffer to send + /// The encoding type to use when converting the buffer + /// This method will store an encoded copy as a memory stream, so be careful with large buffers + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, ContentType type, in ReadOnlySpan data, Encoding encoding) + { + if (data.IsEmpty) + { + ev.CloseResponse(code); + return; + } + + //Validate encoding + _ = encoding ?? throw new ArgumentNullException(nameof(encoding)); + + //Get new simple memory response + IMemoryResponseReader reader = new SimpleMemoryResponse(data, encoding); + ev.CloseResponse(code, type, reader); + } + + /// + /// Close a response to a connection by copying the speciifed binary buffer + /// + /// + /// The response status code + /// The the data represents + /// The binary buffer to send + /// The data paramter is copied into an internal + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CloseResponse(this IHttpEvent ev, HttpStatusCode code, ContentType type, ReadOnlySpan data) + { + if (data.IsEmpty) + { + ev.CloseResponse(code); + return; + } + + //Get new simple memory response + IMemoryResponseReader reader = new SimpleMemoryResponse(data); + ev.CloseResponse(code, type, reader); + } + + /// + /// Close a response to a connection with a relative file within the current root's directory + /// + /// + /// The status code to set the response as + /// The path of the relative file to send + /// True if the file was found, false if the file does not exist or cannot be accessed + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool CloseWithRelativeFile(this HttpEntity entity, HttpStatusCode code, string filePath) + { + //See if file exists and is within the root's directory + if (entity.RequestedRoot.FindResourceInRoot(filePath, out string realPath)) + { + //get file-info + FileInfo realFile = new(realPath); + //Close the response with the file stream + entity.CloseResponse(code, realFile); + return true; + } + return false; + } + + /// + /// Redirects a client using the specified + /// + /// + /// The redirection type + /// Location to direct client to, sets the "Location" header + /// Sets required headers for redirection, disables cache control, and returns the status code to the client + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Redirect(this IHttpEvent ev, RedirectType type, string location) + { + Redirect(ev, type, new Uri(location, UriKind.RelativeOrAbsolute)); + } + + /// + /// Redirects a client using the specified + /// + /// + /// The redirection type + /// Location to direct client to, sets the "Location" header + /// Sets required headers for redirection, disables cache control, and returns the status code to the client + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Redirect(this IHttpEvent ev, RedirectType type, Uri location) + { + //Encode the string for propery http url formatting and set the location header + ev.Server.Headers[HttpResponseHeader.Location] = location.ToString(); + ev.Server.SetNoCache(); + //Set redirect the ressponse redirect code type + ev.CloseResponse((HttpStatusCode)type); + } + + #endregion + + /// + /// Attempts to read and deserialize a JSON object from the reqeust body (form data or urlencoded) + /// + /// + /// + /// Request argument key (name) + /// + /// true if the argument was found and successfully converted to json + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetJsonFromArg(this IHttpEvent ev, string key, out T? obj) => TryGetJsonFromArg(ev, key, SR_OPTIONS, out obj); + + /// + /// Attempts to read and deserialize a JSON object from the reqeust body (form data or urlencoded) + /// + /// + /// + /// Request argument key (name) + /// to use during deserialization + /// + /// true if the argument was found and successfully converted to json + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetJsonFromArg(this IHttpEvent ev, string key, JsonSerializerOptions options, out T? obj) + { + //Check for key in argument + if (ev.RequestArgs.TryGetNonEmptyValue(key, out string? value)) + { + try + { + //Deserialize and return the object + obj = value.AsJsonObject(options); + return true; + } + catch(JsonException je) + { + throw new InvalidJsonRequestException(je); + } + } + obj = default; + return false; + } + + /// + /// Reads the value stored at the key location in the request body arguments, into a + /// + /// + /// Request argument key (name) + /// to use during parsing + /// A new if the key is found, null otherwise + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static JsonDocument? GetJsonFromArg(this IHttpEvent ev, string key, in JsonDocumentOptions options = default) + { + try + { + //Check for key in argument + return ev.RequestArgs.TryGetNonEmptyValue(key, out string? value) ? JsonDocument.Parse(value, options) : null; + } + catch (JsonException je) + { + throw new InvalidJsonRequestException(je); + } + } + + /// + /// If there are file attachements (form data files or content body) and the file is + /// file. It will be deserialzied to the specified object + /// + /// + /// + /// The index within list of the file to read + /// to use during deserialization + /// Returns the deserialized object if found, default otherwise + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T? GetJsonFromFile(this IHttpEvent ev, JsonSerializerOptions? options = null, int uploadIndex = 0) + { + if (ev.Files.Count <= uploadIndex) + { + return default; + } + + FileUpload file = ev.Files[uploadIndex]; + //Make sure the file is a json file + if (file.ContentType != ContentType.Json) + { + return default; + } + try + { + //Beware this will buffer the entire file object before it attmepts to de-serialize it + return VnEncoding.JSONDeserializeFromBinary(file.FileData, options); + } + catch (JsonException je) + { + throw new InvalidJsonRequestException(je); + } + } + + /// + /// If there are file attachements (form data files or content body) and the file is + /// file. It will be parsed into a new + /// + /// + /// The index within list of the file to read + /// Returns the parsed if found, default otherwise + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static JsonDocument? GetJsonFromFile(this IHttpEvent ev, int uploadIndex = 0) + { + if (ev.Files.Count <= uploadIndex) + { + return default; + } + FileUpload file = ev.Files[uploadIndex]; + //Make sure the file is a json file + if (file.ContentType != ContentType.Json) + { + return default; + } + try + { + return JsonDocument.Parse(file.FileData); + } + catch(JsonException je) + { + throw new InvalidJsonRequestException(je); + } + } + + /// + /// If there are file attachements (form data files or content body) and the file is + /// file. It will be deserialzied to the specified object + /// + /// + /// + /// The index within list of the file to read + /// to use during deserialization + /// The deserialized object if found, default otherwise + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask GetJsonFromFileAsync(this HttpEntity ev, JsonSerializerOptions? options = null, int uploadIndex = 0) + { + if (ev.Files.Count <= uploadIndex) + { + return ValueTask.FromResult(default); + } + FileUpload file = ev.Files[uploadIndex]; + //Make sure the file is a json file + if (file.ContentType != ContentType.Json) + { + return ValueTask.FromResult(default); + } + //avoid copying the ev struct, so return deserialze task + static async ValueTask Deserialze(Stream data, JsonSerializerOptions? options, CancellationToken token) + { + try + { + //Beware this will buffer the entire file object before it attmepts to de-serialize it + return await VnEncoding.JSONDeserializeFromBinaryAsync(data, options, token); + } + catch (JsonException je) + { + throw new InvalidJsonRequestException(je); + } + } + return Deserialze(file.FileData, options, ev.EventCancellation); + } + + static readonly Task DocTaskDefault = Task.FromResult(null); + + /// + /// If there are file attachements (form data files or content body) and the file is + /// file. It will be parsed into a new + /// + /// + /// The index within list of the file to read + /// Returns the parsed if found, default otherwise + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Task GetJsonFromFileAsync(this HttpEntity ev, int uploadIndex = 0) + { + if (ev.Files.Count <= uploadIndex) + { + return DocTaskDefault; + } + FileUpload file = ev.Files[uploadIndex]; + //Make sure the file is a json file + if (file.ContentType != ContentType.Json) + { + return DocTaskDefault; + } + static async Task Deserialze(Stream data, CancellationToken token) + { + try + { + //Beware this will buffer the entire file object before it attmepts to de-serialize it + return await JsonDocument.ParseAsync(data, cancellationToken: token); + } + catch (JsonException je) + { + throw new InvalidJsonRequestException(je); + } + } + return Deserialze(file.FileData, ev.EventCancellation); + } + + /// + /// If there are file attachements (form data files or content body) the specified parser will be called to parse the + /// content body asynchronously into a .net object or its default if no attachments are included + /// + /// + /// A function to asynchronously parse the entity body into its object representation + /// The index within list of the file to read + /// Returns the parsed if found, default otherwise + /// + /// + public static Task ParseFileAsAsync(this IHttpEvent ev, Func> parser, int uploadIndex = 0) + { + if (ev.Files.Count <= uploadIndex) + { + return Task.FromResult(default); + } + //Get the file + FileUpload file = ev.Files[uploadIndex]; + return parser(file.FileData); + } + + /// + /// If there are file attachements (form data files or content body) the specified parser will be called to parse the + /// content body asynchronously into a .net object or its default if no attachments are included + /// + /// + /// A function to asynchronously parse the entity body into its object representation + /// The index within list of the file to read + /// Returns the parsed if found, default otherwise + /// + /// + public static Task ParseFileAsAsync(this IHttpEvent ev, Func> parser, int uploadIndex = 0) + { + if (ev.Files.Count <= uploadIndex) + { + return Task.FromResult(default); + } + //Get the file + FileUpload file = ev.Files[uploadIndex]; + //Parse the file using the specified parser + return parser(file.FileData, file.ContentTypeString()); + } + + /// + /// If there are file attachements (form data files or content body) the specified parser will be called to parse the + /// content body asynchronously into a .net object or its default if no attachments are included + /// + /// + /// A function to asynchronously parse the entity body into its object representation + /// The index within list of the file to read + /// Returns the parsed if found, default otherwise + /// + /// + public static ValueTask ParseFileAsAsync(this IHttpEvent ev, Func> parser, int uploadIndex = 0) + { + if (ev.Files.Count <= uploadIndex) + { + return ValueTask.FromResult(default); + } + //Get the file + FileUpload file = ev.Files[uploadIndex]; + return parser(file.FileData); + } + + /// + /// If there are file attachements (form data files or content body) the specified parser will be called to parse the + /// content body asynchronously into a .net object or its default if no attachments are included + /// + /// + /// A function to asynchronously parse the entity body into its object representation + /// The index within list of the file to read + /// Returns the parsed if found, default otherwise + /// + /// + public static ValueTask ParseFileAsAsync(this IHttpEvent ev, Func> parser, int uploadIndex = 0) + { + if (ev.Files.Count <= uploadIndex) + { + return ValueTask.FromResult(default); + } + //Get the file + FileUpload file = ev.Files[uploadIndex]; + //Parse the file using the specified parser + return parser(file.FileData, file.ContentTypeString()); + } + + /// + /// Gets the bearer token from an authorization header + /// + /// + /// The token stored in the user's authorization header + /// True if the authorization header was set, has a Bearer token value + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasAuthorization(this IConnectionInfo ci, [NotNullWhen(true)] out string? token) + { + //Get auth header value + string? authorization = ci.Headers[HttpRequestHeader.Authorization]; + //Check if its set + if (!string.IsNullOrWhiteSpace(authorization)) + { + int bearerIndex = authorization.IndexOf(BEARER_STRING, StringComparison.OrdinalIgnoreCase); + //Calc token offset, get token, and trim any whitespace + token = authorization[(bearerIndex + BEARER_LEN)..].Trim(); + return true; + } + token = null; + return false; + } + + /// + /// Get a instance that points to the current sites filesystem root. + /// + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static DirectoryInfo GetRootDir(this HttpEntity ev) => new(ev.RequestedRoot.Directory); + + /// + /// Returns the MIME string representation of the content type of the uploaded file. + /// + /// + /// The MIME string representation of the content type of the uploaded file. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ContentTypeString(this in FileUpload upload) => HttpHelpers.GetContentTypeString(upload.ContentType); + + + /// + /// Attemts to upgrade the connection to a websocket, if the setup fails, it sets up the response to the client accordingly. + /// + /// + /// A delegate that will be invoked when the websocket has been opened by the framework + /// The sub-protocol to use on the current websocket + /// An object to store in the property when the websocket has been accepted + /// True if operation succeeds. + /// + /// + public static bool AcceptWebSocket(this IHttpEvent entity, WebsocketAcceptedCallback socketOpenedcallback, object? userState, string? subProtocol = null) + { + //Make sure this is a websocket request + if (!entity.Server.IsWebSocketRequest) + { + throw new InvalidOperationException("Connection is not a websocket request"); + } + + //Must define an accept callback + _ = socketOpenedcallback ?? throw new ArgumentNullException(nameof(socketOpenedcallback)); + + string? version = entity.Server.Headers["Sec-WebSocket-Version"]; + + //rfc6455:4.2, version must equal 13 + if (!string.IsNullOrWhiteSpace(version) && version.Contains("13", StringComparison.OrdinalIgnoreCase)) + { + //Get socket key + string? key = entity.Server.Headers["Sec-WebSocket-Key"]; + if (!string.IsNullOrWhiteSpace(key) && key.Length < 25) + { + //Set headers for acceptance + entity.Server.Headers[HttpResponseHeader.Upgrade] = "websocket"; + entity.Server.Headers[HttpResponseHeader.Connection] = "Upgrade"; + + //Hash accept string + entity.Server.Headers["Sec-WebSocket-Accept"] = ManagedHash.ComputeBase64Hash($"{key.Trim()}{HttpHelpers.WebsocketRFC4122Guid}", HashAlg.SHA1); + + //Protocol if user specified it + if (!string.IsNullOrWhiteSpace(subProtocol)) + { + entity.Server.Headers["Sec-WebSocket-Protocol"] = subProtocol; + } + + //Setup a new websocket session with a new session id + entity.DangerousChangeProtocol(new WebSocketSession(subProtocol, socketOpenedcallback) + { + IsSecure = entity.Server.IsSecure(), + UserState = userState + }); + + return true; + } + } + //Set the client up for a bad request response, nod a valid websocket request + entity.CloseResponse(HttpStatusCode.BadRequest); + return false; + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/IJsonSerializerBuffer.cs b/lib/Plugins.Essentials/src/Extensions/IJsonSerializerBuffer.cs new file mode 100644 index 0000000..34811f4 --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/IJsonSerializerBuffer.cs @@ -0,0 +1,48 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IJsonSerializerBuffer.cs +* +* IJsonSerializerBuffer.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.IO; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + /// + /// Interface for a buffer that can be used to serialize objects to JSON + /// + interface IJsonSerializerBuffer + { + /// + /// Gets a stream used for writing serialzation data to + /// + /// The stream to write JSON data to + Stream GetSerialzingStream(); + + /// + /// Called when serialization is complete. + /// The stream may be inspected for the serialized data. + /// + void SerializationComplete(); + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/InternalSerializerExtensions.cs b/lib/Plugins.Essentials/src/Extensions/InternalSerializerExtensions.cs new file mode 100644 index 0000000..3d441a1 --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/InternalSerializerExtensions.cs @@ -0,0 +1,100 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: InternalSerializerExtensions.cs +* +* InternalSerializerExtensions.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 System.IO; +using System.Text.Json; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + + internal static class InternalSerializerExtensions + { + + internal static void Serialize(this Utf8JsonWriter writer, IJsonSerializerBuffer buffer, T value, JsonSerializerOptions? options) + { + //Get stream + Stream output = buffer.GetSerialzingStream(); + try + { + //Reset writer + writer.Reset(output); + + //Serialize + JsonSerializer.Serialize(writer, value, options); + + //flush output + writer.Flush(); + } + finally + { + buffer.SerializationComplete(); + } + } + + internal static void Serialize(this Utf8JsonWriter writer, IJsonSerializerBuffer buffer, object value, Type type, JsonSerializerOptions? options) + { + //Get stream + Stream output = buffer.GetSerialzingStream(); + try + { + //Reset writer + writer.Reset(output); + + //Serialize + JsonSerializer.Serialize(writer, value, type, options); + + //flush output + writer.Flush(); + } + finally + { + buffer.SerializationComplete(); + } + } + + internal static void Serialize(this Utf8JsonWriter writer, IJsonSerializerBuffer buffer, JsonDocument document) + { + //Get stream + Stream output = buffer.GetSerialzingStream(); + try + { + //Reset writer + writer.Reset(output); + + //Serialize + document.WriteTo(writer); + + //flush output + writer.Flush(); + } + finally + { + buffer.SerializationComplete(); + } + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/InvalidJsonRequestException.cs b/lib/Plugins.Essentials/src/Extensions/InvalidJsonRequestException.cs new file mode 100644 index 0000000..b2352b2 --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/InvalidJsonRequestException.cs @@ -0,0 +1,57 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: InvalidJsonRequestException.cs +* +* InvalidJsonRequestException.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.Text.Json; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + /// + /// Wraps a that is thrown when a JSON request message + /// was unsuccessfully parsed. + /// + public class InvalidJsonRequestException : JsonException + { + /// + /// Creates a new wrapper from a base + /// + /// + public InvalidJsonRequestException(JsonException baseExp) + : base(baseExp.Message, baseExp.Path, baseExp.LineNumber, baseExp.BytePositionInLine, baseExp.InnerException) + { + base.HelpLink = baseExp.HelpLink; + base.Source = baseExp.Source; + } + + public InvalidJsonRequestException() + {} + + public InvalidJsonRequestException(string message) : base(message) + {} + + public InvalidJsonRequestException(string message, System.Exception innerException) : base(message, innerException) + {} + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/JsonResponse.cs b/lib/Plugins.Essentials/src/Extensions/JsonResponse.cs new file mode 100644 index 0000000..22cccd9 --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/JsonResponse.cs @@ -0,0 +1,112 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: JsonResponse.cs +* +* JsonResponse.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 System.Buffers; +using System.IO; + +using VNLib.Net.Http; +using VNLib.Utils.Extensions; +using VNLib.Utils.IO; +using VNLib.Utils.Memory; +using VNLib.Utils.Memory.Caching; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + internal sealed class JsonResponse : IJsonSerializerBuffer, IMemoryResponseReader + { + private readonly IObjectRental _pool; + + private readonly MemoryHandle _handle; + private readonly IMemoryOwner _memoryOwner; + //Stream "owns" the handle, so we cannot dispose the stream + private readonly VnMemoryStream _asStream; + + private int _written; + + internal JsonResponse(IObjectRental pool) + { + _pool = pool; + + //Alloc buffer + _handle = Memory.Shared.Alloc(4096, false); + //Consume handle for stream, but make sure not to dispose the stream + _asStream = VnMemoryStream.ConsumeHandle(_handle, 0, false); + //Get memory owner from handle + _memoryOwner = _handle.ToMemoryManager(false); + } + + ~JsonResponse() + { + _handle.Dispose(); + } + + /// + public Stream GetSerialzingStream() + { + //Reset stream position + _asStream.Seek(0, SeekOrigin.Begin); + return _asStream; + } + + /// + public void SerializationComplete() + { + //Reset written position + _written = 0; + //Update remaining pointer + Remaining = Convert.ToInt32(_asStream.Position); + } + + + /// + public int Remaining { get; private set; } + + /// + void IMemoryResponseReader.Advance(int written) + { + //Update position + _written += written; + Remaining -= written; + } + /// + void IMemoryResponseReader.Close() + { + //Reset and return to pool + _written = 0; + Remaining = 0; + //Return self back to pool + _pool.Return(this); + } + + /// + ReadOnlyMemory IMemoryResponseReader.GetMemory() + { + //Get memory from the memory owner and offet the slice, + return _memoryOwner.Memory.Slice(_written, Remaining); + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/RedirectType.cs b/lib/Plugins.Essentials/src/Extensions/RedirectType.cs new file mode 100644 index 0000000..eff4d38 --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/RedirectType.cs @@ -0,0 +1,37 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: RedirectType.cs +* +* RedirectType.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.Net; + +namespace VNLib.Plugins.Essentials.Extensions +{ + /// + /// Shortened list of s for redirecting connections + /// + public enum RedirectType + { + None, + Moved = 301, Found = 302, SeeOther = 303, Temporary = 307, Permanent = 308 + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/SimpleMemoryResponse.cs b/lib/Plugins.Essentials/src/Extensions/SimpleMemoryResponse.cs new file mode 100644 index 0000000..a0f2b17 --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/SimpleMemoryResponse.cs @@ -0,0 +1,89 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: SimpleMemoryResponse.cs +* +* SimpleMemoryResponse.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 System.Text; +using VNLib.Net.Http; +using System.Buffers; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + internal sealed class SimpleMemoryResponse : IMemoryResponseReader + { + private byte[]? _buffer; + private int _written; + + /// + /// Copies the data in the specified buffer to the internal buffer + /// to initalize the new + /// + /// The data to copy + public SimpleMemoryResponse(ReadOnlySpan data) + { + Remaining = data.Length; + //Alloc buffer + _buffer = ArrayPool.Shared.Rent(Remaining); + //Copy data to buffer + data.CopyTo(_buffer); + } + + /// + /// Encodes the character buffer data using the encoder and stores + /// the result in the internal buffer for reading. + /// + /// The data to encode + /// The encoder to use + public SimpleMemoryResponse(ReadOnlySpan data, Encoding enc) + { + //Calc byte count + Remaining = enc.GetByteCount(data); + + //Alloc buffer + _buffer = ArrayPool.Shared.Rent(Remaining); + + //Encode data + Remaining = enc.GetBytes(data, _buffer); + } + + /// + public int Remaining { get; private set; } + /// + void IMemoryResponseReader.Advance(int written) + { + Remaining -= written; + _written += written; + } + /// + void IMemoryResponseReader.Close() + { + //Return buffer to pool + ArrayPool.Shared.Return(_buffer!); + _buffer = null; + } + /// + ReadOnlyMemory IMemoryResponseReader.GetMemory() => _buffer!.AsMemory(_written, Remaining); + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/UserExtensions.cs b/lib/Plugins.Essentials/src/Extensions/UserExtensions.cs new file mode 100644 index 0000000..9223b1d --- /dev/null +++ b/lib/Plugins.Essentials/src/Extensions/UserExtensions.cs @@ -0,0 +1,94 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: UserExtensions.cs +* +* UserExtensions.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 System.Text.Json; + +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Essentials.Accounts; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Extensions +{ + /// + /// Provides extension methods to the Users namespace + /// + public static class UserExtensions + { + + private const string PROFILE_ENTRY = "__.prof"; + + /// + /// Stores the user's profile to their entry. + ///
+ /// NOTE: You must validate/filter data before storing + ///
+ /// + /// The profile object to store on account + /// + /// + public static void SetProfile(this IUser ud, AccountData? profile) + { + //Clear entry if its null + if (profile == null) + { + ud[PROFILE_ENTRY] = null!; + return; + } + //Dont store duplicate values + profile.Created = null; + profile.EmailAddress = null; + ud.SetObject(PROFILE_ENTRY, profile); + } + /// + /// Stores the serialized string user's profile to their entry. + ///
+ /// NOTE: No data validation checks are performed + ///
+ /// + /// The JSON serialized "raw" profile data + public static void SetProfile(this IUser ud, string jsonProfile) => ud[PROFILE_ENTRY] = jsonProfile; + /// + /// Recovers the user's stored profile + /// + /// The user's profile stored in the entry or null if no entry is found + /// + /// + public static AccountData? GetProfile(this IUser ud) + { + //Recover profile data, or create new empty profile data + AccountData? ad = ud.GetObject(PROFILE_ENTRY); + if (ad == null) + { + return null; + } + //Set email the same as the account + ad.EmailAddress = ud.EmailAddress; + //Store the rfc time + ad.Created = ud.Created.ToString("R"); + return ad; + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/FileProcessArgs.cs b/lib/Plugins.Essentials/src/FileProcessArgs.cs new file mode 100644 index 0000000..dae695b --- /dev/null +++ b/lib/Plugins.Essentials/src/FileProcessArgs.cs @@ -0,0 +1,169 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: FileProcessArgs.cs +* +* FileProcessArgs.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 System.Net; + +namespace VNLib.Plugins.Essentials +{ + /// + /// Server routine to follow after processing selector + /// + public enum FpRoutine + { + /// + /// There was an error during processing and the server should immediatly respond with a error code + /// + Error, + /// + /// The server should continue the file read operation with the current information + /// + Continue, + /// + /// The server should redirect the conneciton to an alternate location + /// + Redirect, + /// + /// The server should immediatly respond with a error code + /// + Deny, + /// + /// The server should fulfill the reqeest by sending the contents of an alternate file location (if it exists) with the existing connection + /// + ServeOther, + /// + /// The server should immediatly respond with a error code + /// + NotFound, + /// + /// Serves another file location that must be a trusted fully qualified location + /// + ServeOtherFQ, + /// + /// The connection does not require a file to be processed + /// + VirtualSkip, + } + + /// + /// Specifies operations the file processor will follow during request handling + /// + public readonly struct FileProcessArgs : IEquatable + { + /// + /// Signals the file processor should complete with a routine + /// + public static readonly FileProcessArgs Deny = new (FpRoutine.Deny); + /// + /// Signals the file processor should continue with intended/normal processing of the request + /// + public static readonly FileProcessArgs Continue = new (FpRoutine.Continue); + /// + /// Signals the file processor should complete with a routine + /// + public static readonly FileProcessArgs Error = new (FpRoutine.Error); + /// + /// Signals the file processor should complete with a routine + /// + public static readonly FileProcessArgs NotFound = new (FpRoutine.NotFound); + /// + /// Signals the file processor should not process the connection + /// + public static readonly FileProcessArgs VirtualSkip = new (FpRoutine.VirtualSkip); + /// + /// The routine the file processor should execute + /// + public readonly FpRoutine Routine { get; init; } + /// + /// An optional alternate path for the given routine + /// + public readonly string Alternate { get; init; } + + /// + /// Initializes a new with the specified routine + /// and empty path + /// + /// The file processing routine to execute + public FileProcessArgs(FpRoutine routine) + { + this.Routine = routine; + this.Alternate = string.Empty; + } + /// + /// Initializes a new with the specified routine + /// and alternate path + /// + /// + /// + public FileProcessArgs(FpRoutine routine, string alternatePath) + { + this.Routine = routine; + this.Alternate = alternatePath; + } + /// + /// + /// + /// + /// + /// + public static bool operator == (FileProcessArgs arg1, FileProcessArgs arg2) + { + return arg1.Equals(arg2); + } + /// + /// + /// + /// + /// + /// + public static bool operator != (FileProcessArgs arg1, FileProcessArgs arg2) + { + return !arg1.Equals(arg2); + } + /// + public bool Equals(FileProcessArgs other) + { + //make sure the routine types match + if (Routine != other.Routine) + { + return false; + } + //Next make sure the hashcodes of the alternate paths match + return (Alternate?.GetHashCode(StringComparison.OrdinalIgnoreCase)) == (other.Alternate?.GetHashCode(StringComparison.OrdinalIgnoreCase)); + } + /// + public override bool Equals(object obj) + { + return obj is FileProcessArgs args && Equals(args); + } + /// + /// + /// + /// + public override int GetHashCode() + { + return base.GetHashCode(); + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/HttpEntity.cs b/lib/Plugins.Essentials/src/HttpEntity.cs new file mode 100644 index 0000000..ffad607 --- /dev/null +++ b/lib/Plugins.Essentials/src/HttpEntity.cs @@ -0,0 +1,178 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: HttpEntity.cs +* +* HttpEntity.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 System.IO; +using System.Net; +using System.Threading; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +using VNLib.Net.Http; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Essentials.Extensions; + +#nullable enable + +/* + * HttpEntity was converted to an object as during profiling + * it was almost always heap allcated due to async opertaions + * or other object tracking issues. So to reduce the number of + * allocations (at the cost of larger objects) basic profiling + * showed less GC load and less collections when SessionInfo + * remained a value type + */ +#pragma warning disable CA1051 // Do not declare visible instance fields + +namespace VNLib.Plugins.Essentials +{ + /// + /// A container for an with its attached session. + /// This class cannot be inherited. + /// + public sealed class HttpEntity : IHttpEvent + { + /// + /// The connection event entity + /// + private readonly IHttpEvent Entity; + public HttpEntity(IHttpEvent entity, EventProcessor root, in SessionHandle session, in CancellationToken cancellation) + { + Entity = entity; + RequestedRoot = root; + EventCancellation = cancellation; + + //See if the connection is coming from an downstream server + IsBehindDownStreamServer = root.Options.DownStreamServers.Contains(entity.Server.RemoteEndpoint.Address); + /* + * If the connection was behind a trusted downstream server, + * we can trust the x-forwarded-for header, + * otherwise use the remote ep ip address + */ + TrustedRemoteIp = entity.Server.GetTrustedIp(IsBehindDownStreamServer); + //Initialize the session + Session = session.IsSet ? new(session.SessionData, entity.Server, TrustedRemoteIp) : new(); + //Local connection + IsLocalConnection = entity.Server.LocalEndpoint.Address.IsLocalSubnet(TrustedRemoteIp); + //Cache value + IsSecure = entity.Server.IsSecure(IsBehindDownStreamServer); + } + + /// + /// A token that has a scheduled timeout to signal the cancellation of the entity event + /// + public readonly CancellationToken EventCancellation; + /// + /// The session assocaited with the event + /// + public readonly SessionInfo Session; + /// + /// A value that indicates if the connecion came from a trusted downstream server + /// + public readonly bool IsBehindDownStreamServer; + /// + /// Determines if the connection came from the local network to the current server + /// + public readonly bool IsLocalConnection; + /// + /// Gets a value that determines if the connection is using tls, locally + /// or behind a trusted downstream server that is using tls. + /// + public readonly bool IsSecure; + + /// + /// The connection info object assocated with the entity + /// + public IConnectionInfo Server => Entity.Server; + /// + /// User's ip. If the connection is behind a local proxy, returns the users actual IP. Otherwise returns the connection ip. + /// + public readonly IPAddress TrustedRemoteIp; + /// + /// The requested web root. Provides additional site information + /// + public readonly EventProcessor RequestedRoot; + /// + /// If the request has query arguments they are stored in key value format + /// + public IReadOnlyDictionary QueryArgs => Entity.QueryArgs; + /// + /// If the request body has form data or url encoded arguments they are stored in key value format + /// + public IReadOnlyDictionary RequestArgs => Entity.RequestArgs; + /// + /// Contains all files upladed with current request + /// + public IReadOnlyList Files => Entity.Files; + /// + HttpServer IHttpEvent.OriginServer => Entity.OriginServer; + + /// + /// Complete the session and respond to user + /// + /// Status code of operation + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void CloseResponse(HttpStatusCode code) => Entity.CloseResponse(code); + + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void CloseResponse(HttpStatusCode code, ContentType type, Stream stream) + { + Entity.CloseResponse(code, type, stream); + //Verify content type matches + if (!Server.Accepts(type)) + { + throw new ContentTypeUnacceptableException("The client does not accept the content type of the response"); + } + } + + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void CloseResponse(HttpStatusCode code, ContentType type, IMemoryResponseReader entity) + { + //Verify content type matches + if (!Server.Accepts(type)) + { + throw new ContentTypeUnacceptableException("The client does not accept the content type of the response"); + } + + Entity.CloseResponse(code, type, entity); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void DisableCompression() => Entity.DisableCompression(); + + /* + * Do not directly expose dangerous methods, but allow them to be called + */ + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void IHttpEvent.DangerousChangeProtocol(IAlternateProtocol protocolHandler) => Entity.DangerousChangeProtocol(protocolHandler); + } +} diff --git a/lib/Plugins.Essentials/src/IEpProcessingOptions.cs b/lib/Plugins.Essentials/src/IEpProcessingOptions.cs new file mode 100644 index 0000000..de79327 --- /dev/null +++ b/lib/Plugins.Essentials/src/IEpProcessingOptions.cs @@ -0,0 +1,66 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IEpProcessingOptions.cs +* +* IEpProcessingOptions.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 System.IO; +using System.Net; +using System.Collections.Generic; + +#nullable enable + +namespace VNLib.Plugins.Essentials +{ + /// + /// Provides an interface for + /// security options + /// + public interface IEpProcessingOptions + { + /// + /// The name of a default file to search for within a directory if no file is specified (index.html). + /// This array should be ordered. + /// + IReadOnlyCollection DefaultFiles { get; } + /// + /// File extensions that are denied from being read from the filesystem + /// + IReadOnlySet ExcludedExtensions { get; } + /// + /// File attributes that must be matched for the file to be accessed + /// + FileAttributes AllowedAttributes { get; } + /// + /// Files that match any attribute flag set will be denied + /// + FileAttributes DissallowedAttributes { get; } + /// + /// A table of known downstream servers/ports that can be trusted to proxy connections + /// + IReadOnlySet DownStreamServers { get; } + /// + /// A for how long a connection may remain open before all operations are cancelled + /// + TimeSpan ExecutionTimeout { get; } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Oauth/IOAuth2Provider.cs b/lib/Plugins.Essentials/src/Oauth/IOAuth2Provider.cs new file mode 100644 index 0000000..30944b8 --- /dev/null +++ b/lib/Plugins.Essentials/src/Oauth/IOAuth2Provider.cs @@ -0,0 +1,44 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IOAuth2Provider.cs +* +* IOAuth2Provider.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 System.Threading.Tasks; + +using VNLib.Plugins.Essentials.Sessions; + +namespace VNLib.Plugins.Essentials.Oauth +{ + /// + /// An interface that Oauth2 serice providers must implement + /// to provide sessions to an + /// processor endpoint processor + /// + public interface IOAuth2Provider : ISessionProvider + { + /// + /// Gets a value indicating how long a session may be valid for + /// + public TimeSpan MaxTokenLifetime { get; } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Oauth/O2EndpointBase.cs b/lib/Plugins.Essentials/src/Oauth/O2EndpointBase.cs new file mode 100644 index 0000000..a1a4d35 --- /dev/null +++ b/lib/Plugins.Essentials/src/Oauth/O2EndpointBase.cs @@ -0,0 +1,162 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: O2EndpointBase.cs +* +* O2EndpointBase.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 System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; + +using VNLib.Utils; +using VNLib.Utils.Logging; +using VNLib.Net.Http; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Endpoints; + +namespace VNLib.Plugins.Essentials.Oauth +{ + /// + /// An base class for HttpEntity processors (endpoints) for processing + /// Oauth2 client requests. Similar to + /// but for Oauth2 sessions + /// + public abstract class O2EndpointBase : ResourceEndpointBase + { + //Disable browser only protection + /// + protected override ProtectionSettings EndpointProtectionSettings { get; } = new() { DisableBrowsersOnly = true }; + + /// + public override async ValueTask Process(HttpEntity entity) + { + try + { + VfReturnType rt; + ERRNO preProc = PreProccess(entity); + //Entity was responded to by the pre-processor + if (preProc < 0) + { + return VfReturnType.VirtualSkip; + } + if (preProc == ERRNO.E_FAIL) + { + rt = VfReturnType.Forbidden; + goto Exit; + } + //If websockets are quested allow them to be processed in a logged-in/secure context + if (entity.Server.IsWebSocketRequest) + { + return await WebsocketRequestedAsync(entity); + } + //Capture return type + rt = entity.Server.Method switch + { + //Get request to get account + HttpMethod.GET => await GetAsync(entity), + HttpMethod.POST => await PostAsync(entity), + HttpMethod.DELETE => await DeleteAsync(entity), + HttpMethod.PUT => await PutAsync(entity), + HttpMethod.PATCH => await PatchAsync(entity), + HttpMethod.OPTIONS => await OptionsAsync(entity), + _ => await AlternateMethodAsync(entity, entity.Server.Method), + }; + Exit: + //Write a standard Ouath2 error messag + switch (rt) + { + case VfReturnType.VirtualSkip: + return VfReturnType.VirtualSkip; + case VfReturnType.ProcessAsFile: + return VfReturnType.ProcessAsFile; + case VfReturnType.NotFound: + entity.CloseResponseError(HttpStatusCode.NotFound, ErrorType.InvalidRequest, "The requested resource could not be found"); + return VfReturnType.VirtualSkip; + case VfReturnType.BadRequest: + entity.CloseResponseError(HttpStatusCode.BadRequest, ErrorType.InvalidRequest, "Your request was not properlty formatted and could not be proccessed"); + return VfReturnType.VirtualSkip; + case VfReturnType.Error: + entity.CloseResponseError(HttpStatusCode.InternalServerError, ErrorType.ServerError, "There was a server error processing your request"); + return VfReturnType.VirtualSkip; + + case VfReturnType.Forbidden: + default: + entity.CloseResponseError(HttpStatusCode.Forbidden, ErrorType.InvalidClient, "You do not have access to this resource"); + return VfReturnType.VirtualSkip; + } + } + catch (TerminateConnectionException) + { + //A TC exception is intentional and should always propagate to the runtime + throw; + } + //Re-throw exceptions that are cause by reading the transport layer + catch (IOException ioe) when (ioe.InnerException is SocketException) + { + throw; + } + catch (ContentTypeUnacceptableException) + { + //Respond with an 406 error message + entity.CloseResponseError(HttpStatusCode.NotAcceptable, ErrorType.InvalidRequest, "The response type is not acceptable for this endpoint"); + return VfReturnType.VirtualSkip; + } + catch (InvalidJsonRequestException) + { + //Respond with an error message + entity.CloseResponseError(HttpStatusCode.BadRequest, ErrorType.InvalidRequest, "The request body was not a proper JSON schema"); + return VfReturnType.VirtualSkip; + } + catch (Exception ex) + { + //Log an uncaught excetpion and return an error code (log may not be initialized) + Log?.Error(ex); + //Respond with an error message + entity.CloseResponseError(HttpStatusCode.InternalServerError, ErrorType.ServerError, "There was a server error processing your request"); + return VfReturnType.VirtualSkip; + } + } + + /// + /// Runs base pre-processing and ensures "sessions" OAuth2 token + /// session is loaded + /// + /// The request entity to process + /// + protected override ERRNO PreProccess(HttpEntity entity) + { + //Make sure session is loaded (token is valid) + if (!entity.Session.IsSet) + { + entity.CloseResponseError(HttpStatusCode.Forbidden, ErrorType.InvalidToken, "Your token is not valid"); + return -1; + } + //Must be an oauth session + if (entity.Session.SessionType != Sessions.SessionType.OAuth2) + { + return false; + } + return base.PreProccess(entity); + } + } +} diff --git a/lib/Plugins.Essentials/src/Oauth/OauthHttpExtensions.cs b/lib/Plugins.Essentials/src/Oauth/OauthHttpExtensions.cs new file mode 100644 index 0000000..892a24c --- /dev/null +++ b/lib/Plugins.Essentials/src/Oauth/OauthHttpExtensions.cs @@ -0,0 +1,239 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: OauthHttpExtensions.cs +* +* OauthHttpExtensions.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.Net; +using System.Text; + +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.IO; +using VNLib.Utils.Memory.Caching; +using VNLib.Plugins.Essentials.Extensions; + +namespace VNLib.Plugins.Essentials.Oauth +{ + /// + /// An OAuth2 specification error code + /// + public enum ErrorType + { + /// + /// The request is considered invalid and cannot be continued + /// + InvalidRequest, + /// + /// + /// + InvalidClient, + /// + /// The supplied token is no longer considered valid + /// + InvalidToken, + /// + /// The token does not have the authorization required, is missing authorization, or is no longer considered acceptable + /// + UnauthorizedClient, + /// + /// The client accept content type is unacceptable for the requested endpoint and cannot be processed + /// + UnsupportedResponseType, + /// + /// The scope of the token does not allow for this operation + /// + InvalidScope, + /// + /// There was a server related error and the request could not be fulfilled + /// + ServerError, + /// + /// The request could not be processed at this time + /// + TemporarilyUnabavailable + } + + public static class OauthHttpExtensions + { + private static ThreadLocalObjectStorage SbRental { get; } = ObjectRental.CreateThreadLocal(Constructor, null, ReturnFunc); + + private static StringBuilder Constructor() => new(64); + private static void ReturnFunc(StringBuilder sb) => sb.Clear(); + + /// + /// Closes the current response with a json error message with the message details + /// + /// + /// The http status code + /// The short error + /// The error description message + public static void CloseResponseError(this HttpEntity 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\":\""); + 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); + } + } + /// + /// Closes the current response with a json error message with the message details + /// + /// + /// The http status code + /// The short error + /// The error description message + 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\":\""); + 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("\"}"); + + 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); + } + //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); + } + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Oauth/OauthSessionCacheExhaustedException.cs b/lib/Plugins.Essentials/src/Oauth/OauthSessionCacheExhaustedException.cs new file mode 100644 index 0000000..da91444 --- /dev/null +++ b/lib/Plugins.Essentials/src/Oauth/OauthSessionCacheExhaustedException.cs @@ -0,0 +1,43 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: OauthSessionCacheExhaustedException.cs +* +* OauthSessionCacheExhaustedException.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; + + +namespace VNLib.Plugins.Essentials.Oauth +{ + /// + /// Raised when the session cache space has been exhausted and cannot + /// load the new session into cache. + /// + public class OauthSessionCacheExhaustedException : Exception + { + public OauthSessionCacheExhaustedException(string message) : base(message) + {} + public OauthSessionCacheExhaustedException(string message, Exception innerException) : base(message, innerException) + {} + public OauthSessionCacheExhaustedException() + {} + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Oauth/OauthSessionExtensions.cs b/lib/Plugins.Essentials/src/Oauth/OauthSessionExtensions.cs new file mode 100644 index 0000000..6f9d275 --- /dev/null +++ b/lib/Plugins.Essentials/src/Oauth/OauthSessionExtensions.cs @@ -0,0 +1,88 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: OauthSessionExtensions.cs +* +* OauthSessionExtensions.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.Plugins.Essentials.Sessions; + +namespace VNLib.Plugins.Essentials.Oauth +{ + /// + /// Represents an active oauth session + /// + public static class OauthSessionExtensions + { + public const string APP_ID_ENTRY = "oau.apid"; + public const string REFRESH_TOKEN_ENTRY = "oau.rt"; + public const string SCOPES_ENTRY = "oau.scp"; + public const string TOKEN_TYPE_ENTRY = "oau.typ"; + + + /// + /// The ID of the application that granted the this token access + /// + public static string AppID(this in SessionInfo session) => session[APP_ID_ENTRY]; + + /// + /// The refresh token for this current token + /// + public static string RefreshToken(this in SessionInfo session) => session[REFRESH_TOKEN_ENTRY]; + + /// + /// The token's privilage scope + /// + public static string Scopes(this in SessionInfo session) => session[SCOPES_ENTRY]; + /// + /// The Oauth2 token type + /// , + public static string Type(this in SessionInfo session) => session[TOKEN_TYPE_ENTRY]; + + /// + /// Determines if the current session has the required scope type and the + /// specified permission + /// + /// + /// The scope type + /// The scope permission + /// True if the current session has the required scope, false otherwise + public static bool HasScope(this in SessionInfo session, string type, string permission) + { + //Join the permissions components + string perms = string.Concat(type, ":", permission); + return session.HasScope(perms); + } + /// + /// Determines if the current session has the required scope type and the + /// specified permission + /// + /// + /// The scope to compare + /// True if the current session has the required scope, false otherwise + public static bool HasScope(this in SessionInfo session, ReadOnlySpan scope) + { + //Split the scope components and check them against the joined permission + return session.Scopes().AsSpan().Contains(scope, StringComparison.OrdinalIgnoreCase); + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Sessions/ISession.cs b/lib/Plugins.Essentials/src/Sessions/ISession.cs new file mode 100644 index 0000000..e15c6e2 --- /dev/null +++ b/lib/Plugins.Essentials/src/Sessions/ISession.cs @@ -0,0 +1,94 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: ISession.cs +* +* ISession.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 System.Net; + +using VNLib.Utils; + +namespace VNLib.Plugins.Essentials.Sessions +{ + /// + /// Flags to specify session types + /// + public enum SessionType + { + /// + /// The session is a "basic" or web based session + /// + Web, + /// + /// The session is an OAuth2 session type + /// + OAuth2 + } + + /// + /// Represents a connection oriented session data + /// + public interface ISession : IIndexable + { + /// + /// A value specifying the type of the loaded session + /// + SessionType SessionType { get; } + /// + /// UTC time in when the session was created + /// + DateTimeOffset Created { get; } + /// + /// Privilages associated with user specified during login + /// + ulong Privilages { get; set; } + /// + /// Key that identifies the current session. (Identical to cookie::sessionid) + /// + string SessionID { get; } + /// + /// User ID associated with session + /// + string UserID { get; set; } + /// + /// Marks the session as invalid + /// + void Invalidate(bool all = false); + /// + /// Gets or sets the session's authorization token + /// + string Token { get; set; } + /// + /// The IP address belonging to the client + /// + IPAddress UserIP { get; } + /// + /// Sets the session ID to be regenerated if applicable + /// + void RegenID(); + + /// + /// A value that indicates this session was newly created + /// + bool IsNew { get; } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Sessions/ISessionExtensions.cs b/lib/Plugins.Essentials/src/Sessions/ISessionExtensions.cs new file mode 100644 index 0000000..44063f9 --- /dev/null +++ b/lib/Plugins.Essentials/src/Sessions/ISessionExtensions.cs @@ -0,0 +1,95 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: ISessionExtensions.cs +* +* ISessionExtensions.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 System.Runtime.CompilerServices; +using System.Security.Authentication; + +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.Extensions; + +namespace VNLib.Plugins.Essentials.Sessions +{ + public static class ISessionExtensions + { + public const string USER_AGENT_ENTRY = "__.ua"; + public const string ORIGIN_ENTRY = "__.org"; + public const string REFER_ENTRY = "__.rfr"; + public const string SECURE_ENTRY = "__.sec"; + public const string CROSS_ORIGIN = "__.cor"; + public const string LOCAL_TIME_ENTRY = "__.lot"; + public const string LOGIN_TOKEN_ENTRY = "__.lte"; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetUserAgent(this ISession session) => session[USER_AGENT_ENTRY]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetUserAgent(this ISession session, string userAgent) => session[USER_AGENT_ENTRY] = userAgent; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetOrigin(this ISession session) => session[ORIGIN_ENTRY]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Uri GetOriginUri(this ISession session) => Uri.TryCreate(session[ORIGIN_ENTRY], UriKind.Absolute, out Uri origin) ? origin : null; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetOrigin(this ISession session, string origin) => session[ORIGIN_ENTRY] = origin; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetRefer(this ISession session) => session[REFER_ENTRY]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetRefer(this ISession session, string refer) => session[REFER_ENTRY] = refer; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static SslProtocols GetSecurityProtocol(this ISession session) + { + return (SslProtocols)session.GetValueType(SECURE_ENTRY); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetSecurityProtocol(this ISession session, SslProtocols protocol) => session[SECURE_ENTRY] = VnEncoding.ToBase32String((int)protocol); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsCrossOrigin(this ISession session) => session[CROSS_ORIGIN] == bool.TrueString; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void IsCrossOrigin(this ISession session, bool crossOrign) => session[CROSS_ORIGIN] = crossOrign.ToString(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string GetLoginToken(this ISession session) => session[LOGIN_TOKEN_ENTRY]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetLoginToken(this ISession session, string loginToken) => session[LOGIN_TOKEN_ENTRY] = loginToken; + + /// + /// Initializes a "new" session with initial varaibles from the current connection + /// for lookup/comparison later + /// + /// + /// The object containing connection details + public static void InitNewSession(this ISession session, IConnectionInfo ci) + { + session.IsCrossOrigin(ci.CrossOrigin); + session.SetOrigin(ci.Origin?.ToString()); + session.SetRefer(ci.Referer?.ToString()); + session.SetSecurityProtocol(ci.SecurityProtocol); + session.SetUserAgent(ci.UserAgent); + } + + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Sessions/ISessionProvider.cs b/lib/Plugins.Essentials/src/Sessions/ISessionProvider.cs new file mode 100644 index 0000000..fe7e7ce --- /dev/null +++ b/lib/Plugins.Essentials/src/Sessions/ISessionProvider.cs @@ -0,0 +1,49 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: ISessionProvider.cs +* +* ISessionProvider.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 System.Threading; +using System.Threading.Tasks; + +using VNLib.Net.Http; + +namespace VNLib.Plugins.Essentials.Sessions +{ + /// + /// Provides stateful session objects assocated with HTTP connections + /// + public interface ISessionProvider + { + /// + /// Gets a session handle for the current connection + /// + /// The connection to get associated session on + /// + /// A task the resolves an instance + /// + /// + /// + public ValueTask GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken); + } +} diff --git a/lib/Plugins.Essentials/src/Sessions/SessionBase.cs b/lib/Plugins.Essentials/src/Sessions/SessionBase.cs new file mode 100644 index 0000000..d386b8b --- /dev/null +++ b/lib/Plugins.Essentials/src/Sessions/SessionBase.cs @@ -0,0 +1,168 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: SessionBase.cs +* +* SessionBase.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 System.Net; +using System.Runtime.CompilerServices; + +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.Async; + +namespace VNLib.Plugins.Essentials.Sessions +{ + /// + /// Provides a base class for the interface for exclusive use within a multithreaded + /// context + /// + public abstract class SessionBase : AsyncExclusiveResource, ISession + { + protected const ulong MODIFIED_MSK = 0b0000000000000001UL; + protected const ulong IS_NEW_MSK = 0b0000000000000010UL; + protected const ulong REGEN_ID_MSK = 0b0000000000000100UL; + protected const ulong INVALID_MSK = 0b0000000000001000UL; + protected const ulong ALL_INVALID_MSK = 0b0000000000100000UL; + + protected const string USER_ID_ENTRY = "__.i.uid"; + protected const string TOKEN_ENTRY = "__.i.tk"; + protected const string PRIV_ENTRY = "__.i.pl"; + protected const string IP_ADDRESS_ENTRY = "__.i.uip"; + protected const string SESSION_TYPE_ENTRY = "__.i.tp"; + + /// + /// A of status flags for the state of the current session. + /// May be used internally + /// + protected BitField Flags { get; } = new(0); + + /// + /// Gets or sets the Modified flag + /// + protected bool IsModified + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Flags.IsSet(MODIFIED_MSK); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => Flags.Set(MODIFIED_MSK, value); + } + + /// + public virtual string SessionID { get; protected set; } + /// + public virtual DateTimeOffset Created { get; protected set; } + + /// + /// + public string this[string index] + { + get + { + Check(); + return IndexerGet(index); + } + set + { + Check(); + IndexerSet(index, value); + } + } + /// + public virtual IPAddress UserIP + { + get + { + //try to parse the IP address, otherwise return null + _ = IPAddress.TryParse(this[IP_ADDRESS_ENTRY], out IPAddress ip); + return ip; + } + protected set + { + //Store the IP address as its string representation + this[IP_ADDRESS_ENTRY] = value?.ToString(); + } + } + /// + public virtual SessionType SessionType + { + get => Enum.Parse(this[SESSION_TYPE_ENTRY]); + protected set => this[SESSION_TYPE_ENTRY] = ((byte)value).ToString(); + } + + /// + public virtual ulong Privilages + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Convert.ToUInt64(this[PRIV_ENTRY], 16); + //Store in hexadecimal to conserve space + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => this[PRIV_ENTRY] = value.ToString("X"); + } + /// + public bool IsNew + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Flags.IsSet(IS_NEW_MSK); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => Flags.Set(IS_NEW_MSK, value); + } + /// + public virtual string UserID + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => this[USER_ID_ENTRY]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => this[USER_ID_ENTRY] = value; + } + /// + public virtual string Token + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => this[TOKEN_ENTRY]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => this[TOKEN_ENTRY] = value; + } + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public virtual void Invalidate(bool all = false) + { + Flags.Set(INVALID_MSK); + Flags.Set(ALL_INVALID_MSK, all); + } + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public virtual void RegenID() => Flags.Set(REGEN_ID_MSK); + /// + /// Invoked when the indexer is is called to + /// + /// The key/index to get the value for + /// The value stored at the specified key + protected abstract string IndexerGet(string key); + /// + /// Sets a value requested by the indexer + /// + /// The key to associate the value with + /// The value to store + protected abstract void IndexerSet(string key, string value); + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Sessions/SessionCacheLimitException.cs b/lib/Plugins.Essentials/src/Sessions/SessionCacheLimitException.cs new file mode 100644 index 0000000..e2bc01b --- /dev/null +++ b/lib/Plugins.Essentials/src/Sessions/SessionCacheLimitException.cs @@ -0,0 +1,41 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: SessionCacheLimitException.cs +* +* SessionCacheLimitException.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; + +namespace VNLib.Plugins.Essentials.Sessions +{ + /// + /// Raised when the maximum number of cache entires has been reached, and the new session cannot be processed + /// + public class SessionCacheLimitException : SessionException + { + public SessionCacheLimitException(string message) : base(message) + {} + public SessionCacheLimitException(string message, Exception innerException) : base(message, innerException) + {} + public SessionCacheLimitException() + {} + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Sessions/SessionException.cs b/lib/Plugins.Essentials/src/Sessions/SessionException.cs new file mode 100644 index 0000000..554c55f --- /dev/null +++ b/lib/Plugins.Essentials/src/Sessions/SessionException.cs @@ -0,0 +1,48 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: SessionException.cs +* +* SessionException.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 System.Runtime.Serialization; + +namespace VNLib.Plugins.Essentials.Sessions +{ + /// + /// A base class for all session exceptions + /// + public class SessionException : Exception + { + /// + public SessionException() + {} + /// + public SessionException(string message) : base(message) + {} + /// + public SessionException(string message, Exception innerException) : base(message, innerException) + {} + /// + protected SessionException(SerializationInfo info, StreamingContext context) : base(info, context) + {} + } +} diff --git a/lib/Plugins.Essentials/src/Sessions/SessionHandle.cs b/lib/Plugins.Essentials/src/Sessions/SessionHandle.cs new file mode 100644 index 0000000..15c2743 --- /dev/null +++ b/lib/Plugins.Essentials/src/Sessions/SessionHandle.cs @@ -0,0 +1,123 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: SessionHandle.cs +* +* SessionHandle.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 System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; + +using VNLib.Net.Http; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Sessions +{ + public delegate ValueTask SessionReleaseCallback(ISession session, IHttpEvent @event); + + /// + /// A handle that holds exclusive access to a + /// session object + /// + public readonly struct SessionHandle : IEquatable + { + /// + /// An empty instance. (A handle without a session object) + /// + public static readonly SessionHandle Empty = new(null, FileProcessArgs.Continue, null); + + private readonly SessionReleaseCallback? ReleaseCb; + + internal readonly bool IsSet => SessionData != null; + + /// + /// The session data object associated with the current session + /// + public readonly ISession? SessionData { get; } + + /// + /// A value indicating if the connection is valid and should continue to be processed + /// + public readonly FileProcessArgs EntityStatus { get; } + + /// + /// Initializes a new + /// + /// The session data instance + /// A callback that is invoked when the handle is released + /// + public SessionHandle(ISession? sessionData, FileProcessArgs entityStatus, SessionReleaseCallback? callback) + { + SessionData = sessionData; + ReleaseCb = callback; + EntityStatus = entityStatus; + } + /// + /// Initializes a new + /// + /// The session data instance + /// A callback that is invoked when the handle is released + public SessionHandle(ISession sessionData, SessionReleaseCallback callback):this(sessionData, FileProcessArgs.Continue, callback) + {} + + /// + /// Releases the session from use + /// + /// The current connection event object + public ValueTask ReleaseAsync(IHttpEvent @event) => ReleaseCb?.Invoke(SessionData!, @event) ?? ValueTask.CompletedTask; + + /// + /// Determines if another is equal to the current handle. + /// Handles are equal if neither handle is set or if their SessionData object is equal. + /// + /// The other handle to + /// true if neither handle is set or if their SessionData object is equal, false otherwise + public bool Equals(SessionHandle other) + { + //If neither handle is set, then they are equal, otherwise they are equal if the session objects themselves are equal + return (!IsSet && !other.IsSet) || (SessionData?.Equals(other.SessionData) ?? false); + } + /// + public override bool Equals([NotNullWhen(true)] object? obj) => (obj is SessionHandle other) && Equals(other); + /// + public override int GetHashCode() + { + return IsSet ? SessionData!.GetHashCode() : base.GetHashCode(); + } + + /// + /// Checks if two instances are equal + /// + /// + /// + /// + public static bool operator ==(SessionHandle left, SessionHandle right) => left.Equals(right); + + /// + /// Checks if two instances are not equal + /// + /// + /// + /// + public static bool operator !=(SessionHandle left, SessionHandle right) => !(left == right); + } +} diff --git a/lib/Plugins.Essentials/src/Sessions/SessionInfo.cs b/lib/Plugins.Essentials/src/Sessions/SessionInfo.cs new file mode 100644 index 0000000..13e2a84 --- /dev/null +++ b/lib/Plugins.Essentials/src/Sessions/SessionInfo.cs @@ -0,0 +1,231 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: SessionInfo.cs +* +* SessionInfo.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 System.Net; +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 + * the HttpEntity object, so have a single larger object + * passed by ref, and created once per request. It may even + * be cached and reused in the future. But for now user-apis + * should not be cached until a safe use policy is created. + */ + +#pragma warning disable CA1051 // Do not declare visible instance fields + +namespace VNLib.Plugins.Essentials.Sessions +{ + /// + /// When attached to a connection, provides persistant session storage and inforamtion based + /// on a connection. + /// + public readonly struct SessionInfo : IObjectStorage, IEquatable + { + /// + /// A value indicating if the current instance has been initiailzed + /// with a session. Otherwise properties are undefied + /// + public readonly bool IsSet; + + private readonly ISession UserSession; + /// + /// Key that identifies the current session. (Identical to cookie::sessionid) + /// + public readonly string SessionID; + /// + /// Session stored User-Agent + /// + public readonly string UserAgent; + /// + /// If the stored IP and current user's IP matches + /// + public readonly bool IPMatch; + /// + /// If the current connection and stored session have matching cross origin domains + /// + public readonly bool CrossOriginMatch; + /// + /// Flags the session as invalid. IMPORTANT: the user's session data is no longer valid and will throw an exception when accessed + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Invalidate(bool all = false) => UserSession.Invalidate(all); + /// + /// Marks the session ID to be regenerated during closing event + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void RegenID() => UserSession.RegenID(); + /// + public T GetObject(string key) + { + //Attempt to deserialze the object, or return default if it is empty + return this[key].AsJsonObject(SR_OPTIONS); + } + /// + public void SetObject(string key, T obj) + { + //Serialize and store the object, or set null (remove) if the object is null + this[key] = obj?.ToJsonString(SR_OPTIONS); + } + + /// + /// Was the original session cross origin? + /// + public readonly bool CrossOrigin; + /// + /// The origin header specified during session creation + /// + public readonly Uri SpecifiedOrigin; + /// + /// Privilages associated with user specified during login + /// + public readonly DateTimeOffset Created; + /// + /// Was this session just created on this connection? + /// + public readonly bool IsNew; + /// + /// Gets or sets the session's login hash, if set to a non-empty/null value, will trigger an upgrade on close + /// + public readonly string LoginHash + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => UserSession.GetLoginToken(); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => UserSession.SetLoginToken(value); + } + /// + /// Gets or sets the session's login token, if set to a non-empty/null value, will trigger an upgrade on close + /// + public readonly string Token + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => UserSession.Token; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => UserSession.Token = value; + } + /// + /// + /// Gets or sets the user-id for the current session. + /// + /// + /// Login code usually sets this value and it should be read-only + /// + /// + public readonly string UserID + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => UserSession.UserID; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => UserSession.UserID = value; + } + /// + /// Privilages associated with user specified during login + /// + public readonly ulong Privilages + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => UserSession.Privilages; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => UserSession.Privilages = value; + } + /// + /// The IP address belonging to the client + /// + public readonly IPAddress UserIP; + /// + /// Was the session Initialy established on a secure connection? + /// + public readonly SslProtocols SecurityProcol; + /// + /// A value specifying the type of the backing session + /// + public readonly SessionType SessionType => UserSession.SessionType; + + /// + /// Accesses the session's general storage + /// + /// Key for specifie data + /// Value associated with the key from the session's general storage + public readonly string this[string index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => UserSession[index]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + set => UserSession[index] = value; + } + + internal SessionInfo(ISession session, IConnectionInfo ci, IPAddress trueIp) + { + UserSession = session; + //Calculate and store + IsNew = session.IsNew; + SessionID = session.SessionID; + Created = session.Created; + UserIP = session.UserIP; + //Ip match + IPMatch = trueIp.Equals(session.UserIP); + //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; + } + else + { + //Load/decode stored variables + UserAgent = session.GetUserAgent(); + SpecifiedOrigin = session.GetOriginUri(); + CrossOrigin = session.IsCrossOrigin(); + SecurityProcol = session.GetSecurityProtocol(); + } + CrossOriginMatch = ci.Origin != null && ci.Origin.Equals(SpecifiedOrigin); + IsSet = true; + } + + /// + public bool Equals(SessionInfo other) => SessionID.Equals(other.SessionID, StringComparison.Ordinal); + /// + public override bool Equals(object obj) => obj is SessionInfo si && Equals(si); + /// + public override int GetHashCode() => SessionID.GetHashCode(StringComparison.Ordinal); + /// + public static bool operator ==(SessionInfo left, SessionInfo right) => left.Equals(right); + /// + public static bool operator !=(SessionInfo left, SessionInfo right) => !(left == right); + + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Statics.cs b/lib/Plugins.Essentials/src/Statics.cs new file mode 100644 index 0000000..58b5dd7 --- /dev/null +++ b/lib/Plugins.Essentials/src/Statics.cs @@ -0,0 +1,44 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: Statics.cs +* +* Statics.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 System.Text.Json; +using System.Text.Json.Serialization; + +namespace VNLib.Plugins.Essentials +{ + public static class Statics + { + public static readonly JsonSerializerOptions SR_OPTIONS = new() + { + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + NumberHandling = JsonNumberHandling.Strict, + ReadCommentHandling = JsonCommentHandling.Disallow, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + IgnoreReadOnlyFields = true, + DefaultBufferSize = Environment.SystemPageSize, + }; + } +} diff --git a/lib/Plugins.Essentials/src/TimestampedCounter.cs b/lib/Plugins.Essentials/src/TimestampedCounter.cs new file mode 100644 index 0000000..19cb8ec --- /dev/null +++ b/lib/Plugins.Essentials/src/TimestampedCounter.cs @@ -0,0 +1,117 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: TimestampedCounter.cs +* +* TimestampedCounter.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 +{ + /// + /// Stucture that allows for convient storage of a counter value + /// and a second precision timestamp into a 64-bit unsigned integer + /// + public readonly struct TimestampedCounter : IEquatable + { + /// + /// The time the count was last modifed + /// + public readonly DateTimeOffset LastModified; + /// + /// The last failed login attempt count value + /// + public readonly uint Count; + + /// + /// Initalizes a new flc structure with the current UTC date + /// and the specified count value + /// + /// FLC current count + public TimestampedCounter(uint count) : this(DateTimeOffset.UtcNow, count) + { } + + private TimestampedCounter(DateTimeOffset dto, uint count) + { + Count = count; + LastModified = dto; + } + + /// + /// Compacts and converts the counter value and timestamp into + /// a 64bit unsigned integer + /// + /// The counter to convert + public static explicit operator ulong(TimestampedCounter count) => count.ToUInt64(); + + /// + /// Compacts and converts the counter value and timestamp into + /// a 64bit unsigned integer + /// + /// The uint64 compacted value + public ulong ToUInt64() + { + //Upper 32 bits time, lower 32 bits count + ulong value = (ulong)LastModified.ToUnixTimeSeconds() << 32; + value |= Count; + return value; + } + + /// + /// The previously compacted + /// value to cast back to a counter + /// + /// + public static explicit operator TimestampedCounter(ulong value) => FromUInt64(value); + + /// + public override bool Equals(object? obj) => obj is TimestampedCounter counter && Equals(counter); + /// + public override int GetHashCode() => this.ToUInt64().GetHashCode(); + /// + public static bool operator ==(TimestampedCounter left, TimestampedCounter right) => left.Equals(right); + /// + public static bool operator !=(TimestampedCounter left, TimestampedCounter right) => !(left == right); + /// + public bool Equals(TimestampedCounter other) => ToUInt64() == other.ToUInt64(); + + /// + /// The previously compacted + /// value to cast back to a counter + /// + /// The uint64 encoded + /// + /// The decoded from its + /// compatcted representation + /// + public static TimestampedCounter FromUInt64(ulong value) + { + //Upper 32 bits time, lower 32 bits count + long time = (long)(value >> 32); + uint count = (uint)(value & uint.MaxValue); + //Init dto struct + return new(DateTimeOffset.FromUnixTimeSeconds(time), count); + } + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Users/IUser.cs b/lib/Plugins.Essentials/src/Users/IUser.cs new file mode 100644 index 0000000..28c5305 --- /dev/null +++ b/lib/Plugins.Essentials/src/Users/IUser.cs @@ -0,0 +1,74 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IUser.cs +* +* IUser.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 System.Collections.Generic; + +using VNLib.Utils; +using VNLib.Utils.Async; +using VNLib.Utils.Memory; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Users +{ + /// + /// Represents an abstract user account + /// + public interface IUser : IAsyncExclusiveResource, IDisposable, IObjectStorage, IEnumerable>, IIndexable + { + /// + /// The user's privilage level + /// + ulong Privilages { get; } + /// + /// The user's ID + /// + string UserID { get; } + /// + /// Date the user's account was created + /// + DateTimeOffset Created { get; } + /// + /// The user's password hash if retreived from the backing store, otherwise null + /// + PrivateString? PassHash { get; } + /// + /// Status of account + /// + UserStatus Status { get; set; } + /// + /// Is the account only usable from local network? + /// + bool LocalOnly { get; set; } + /// + /// The user's email address + /// + string EmailAddress { get; set; } + /// + /// Marks the user for deletion on release + /// + void Delete(); + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Users/IUserManager.cs b/lib/Plugins.Essentials/src/Users/IUserManager.cs new file mode 100644 index 0000000..dd521e4 --- /dev/null +++ b/lib/Plugins.Essentials/src/Users/IUserManager.cs @@ -0,0 +1,103 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: IUserManager.cs +* +* IUserManager.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 System.Threading; +using System.Threading.Tasks; + +using VNLib.Utils; +using VNLib.Utils.Memory; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Users +{ + /// + /// A backing store that provides user accounts + /// + public interface IUserManager + { + /// + /// Attempts to get a user object without their password from the database asynchronously + /// + /// The id of the user + /// A token to cancel the operation + /// The user's object, null if the user was not found + /// + Task GetUserFromIDAsync(string userId, CancellationToken cancellationToken = default); + /// + /// Attempts to get a user object without their password from the database asynchronously + /// + /// The user's email address + /// A token to cancel the operation + /// The user's object, null if the user was not found + /// + Task GetUserFromEmailAsync(string emailAddress, CancellationToken cancellationToken = default); + /// + /// Attempts to get a user object with their password from the database on the current thread + /// + /// The id of the user + /// A token to cancel the operation + /// The user's object, null if the user was not found + /// + Task GetUserAndPassFromIDAsync(string userid, CancellationToken cancellation = default); + /// + /// Attempts to get a user object with their password from the database asynchronously + /// + /// The user's email address + /// A token to cancel the operation + /// The user's object, null if the user was not found + /// + Task GetUserAndPassFromEmailAsync(string emailAddress, CancellationToken cancellationToken = default); + /// + /// Creates a new user in the current user's table and if successful returns the new user object (without password) + /// + /// The user id + /// A number representing the privilage level of the account + /// Value to store in the password field + /// A token to cancel the operation + /// The account email address + /// An object representing a user's account if successful, null otherwise + /// + /// + /// + Task CreateUserAsync(string userid, string emailAddress, ulong privilages, PrivateString passHash, CancellationToken cancellation = default); + /// + /// Updates a password associated with the specified user. If the update fails, the transaction + /// is rolled back. + /// + /// The user account to update the password of + /// The new password to set + /// A token to cancel the operation + /// The result of the operation, the result should be 1 (aka true) + Task UpdatePassAsync(IUser user, PrivateString newPass, CancellationToken cancellation = default); + + /// + /// Gets the number of entries in the current user table + /// + /// A token to cancel the operation + /// The number of users in the table, or -1 if the operation failed + Task GetUserCountAsync(CancellationToken cancellation = default); + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Users/UserCreationFailedException.cs b/lib/Plugins.Essentials/src/Users/UserCreationFailedException.cs new file mode 100644 index 0000000..9f509ac --- /dev/null +++ b/lib/Plugins.Essentials/src/Users/UserCreationFailedException.cs @@ -0,0 +1,47 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: UserCreationFailedException.cs +* +* UserCreationFailedException.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 System.Runtime.Serialization; +using VNLib.Utils.Resources; + +#nullable enable + +namespace VNLib.Plugins.Essentials.Users +{ + /// + /// Raised when a user creation operation has failed and could not be created + /// + public class UserCreationFailedException : ResourceUpdateFailedException + { + public UserCreationFailedException() + {} + public UserCreationFailedException(string message) : base(message) + {} + public UserCreationFailedException(string message, Exception innerException) : base(message, innerException) + {} + protected UserCreationFailedException(SerializationInfo info, StreamingContext context) : base(info, context) + {} + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Users/UserDeleteException.cs b/lib/Plugins.Essentials/src/Users/UserDeleteException.cs new file mode 100644 index 0000000..cd26543 --- /dev/null +++ b/lib/Plugins.Essentials/src/Users/UserDeleteException.cs @@ -0,0 +1,44 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: UserDeleteException.cs +* +* UserDeleteException.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.Resources; + +namespace VNLib.Plugins.Essentials.Users +{ + /// + /// Raised when a user flagged for deletion could not be deleted. See the + /// for the Exception that cause the opertion to fail + /// + public class UserDeleteException : ResourceDeleteFailedException + { + public UserDeleteException(string message, Exception cause) : base(message, cause) { } + + public UserDeleteException() + {} + + public UserDeleteException(string message) : base(message) + {} + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Users/UserExistsException.cs b/lib/Plugins.Essentials/src/Users/UserExistsException.cs new file mode 100644 index 0000000..5c63547 --- /dev/null +++ b/lib/Plugins.Essentials/src/Users/UserExistsException.cs @@ -0,0 +1,49 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: UserExistsException.cs +* +* UserExistsException.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 System.Runtime.Serialization; + +namespace VNLib.Plugins.Essentials.Users +{ + /// + /// Raised when an operation + /// fails because the user account already exists + /// + public class UserExistsException : UserCreationFailedException + { + /// + public UserExistsException() + {} + /// + public UserExistsException(string message) : base(message) + {} + /// + public UserExistsException(string message, Exception innerException) : base(message, innerException) + {} + /// + protected UserExistsException(SerializationInfo info, StreamingContext context) : base(info, context) + {} + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Users/UserStatus.cs b/lib/Plugins.Essentials/src/Users/UserStatus.cs new file mode 100644 index 0000000..32aa63d --- /dev/null +++ b/lib/Plugins.Essentials/src/Users/UserStatus.cs @@ -0,0 +1,50 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: UserStatus.cs +* +* UserStatus.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/. +*/ + +namespace VNLib.Plugins.Essentials.Users +{ + public enum UserStatus + { + /// + /// Unverified account state + /// + Unverified, + /// + /// Active account state. The account is fully functional + /// + Active, + /// + /// The account is suspended + /// + Suspended, + /// + /// The account is inactive as marked by the system + /// + Inactive, + /// + /// The account has been locked from access + /// + Locked + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Users/UserUpdateException.cs b/lib/Plugins.Essentials/src/Users/UserUpdateException.cs new file mode 100644 index 0000000..391bb05 --- /dev/null +++ b/lib/Plugins.Essentials/src/Users/UserUpdateException.cs @@ -0,0 +1,43 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: UserUpdateException.cs +* +* UserUpdateException.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.Resources; + +namespace VNLib.Plugins.Essentials.Users +{ + /// + /// Raised when a user-data object was modified and an update operation failed + /// + public class UserUpdateException : ResourceUpdateFailedException + { + public UserUpdateException(string message, Exception cause) : base(message, cause) { } + + public UserUpdateException() + {} + + public UserUpdateException(string message) : base(message) + {} + } +} \ No newline at end of file diff --git a/lib/Plugins.Essentials/src/VNLib.Plugins.Essentials.csproj b/lib/Plugins.Essentials/src/VNLib.Plugins.Essentials.csproj new file mode 100644 index 0000000..710e8af --- /dev/null +++ b/lib/Plugins.Essentials/src/VNLib.Plugins.Essentials.csproj @@ -0,0 +1,53 @@ + + + + net6.0 + VNLib.Plugins.Essentials + $(Authors) + Vaughn Nugent + VNLib Essentials Plugin Library + Copyright © 2022 Vaughn Nugent + + + Provides essential web, user, storage, and database interaction features for use with web applications + https://www.vaughnnugent.com/resources + VNLib.Plugins.Essentials + True + \\vaughnnugent.com\Internal\Folder Redirection\vman\Documents\Programming\Software\StrongNameingKey.snk + + + + + true + VNLib, Plugins, VNLib.Plugins.Essentials, Essentials, Essential Plugins, HTTP Essentials, OAuth2 + True + 1.0.1.3 + latest-all + True + + + False + + + False + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/lib/Plugins.Essentials/src/WebSocketSession.cs b/lib/Plugins.Essentials/src/WebSocketSession.cs new file mode 100644 index 0000000..106501c --- /dev/null +++ b/lib/Plugins.Essentials/src/WebSocketSession.cs @@ -0,0 +1,204 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: WebSocketSession.cs +* +* WebSocketSession.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 System.IO; +using System.Threading; +using System.Net.WebSockets; +using System.Threading.Tasks; + +using VNLib.Net.Http; + +#nullable enable + +namespace VNLib.Plugins.Essentials +{ + /// + /// A callback method to invoke when an HTTP service successfully transfers protocols to + /// the WebSocket protocol and the socket is ready to be used + /// + /// The open websocket session instance + /// + /// A that will be awaited by the HTTP layer. When the task completes, the transport + /// will be closed and the session disposed + /// + + public delegate Task WebsocketAcceptedCallback(WebSocketSession session); + + /// + /// Represents a wrapper to manage the lifetime of the captured + /// connection context and the underlying transport. This session is managed by the parent + /// that it was created on. + /// + public sealed class WebSocketSession : AlternateProtocolBase + { + private WebSocket? WsHandle; + private readonly WebsocketAcceptedCallback AcceptedCallback; + + /// + /// A cancellation token that can be monitored to reflect the state + /// of the webscocket + /// + public CancellationToken Token => CancelSource.Token; + + /// + /// Id assigned to this instance on creation + /// + public string SocketID { get; } + + /// + /// Negotiated sub-protocol + /// + public string? SubProtocol { get; } + + /// + /// A user-defined state object passed during socket accept handshake + /// + public object? UserState { get; internal set; } + + internal WebSocketSession(string? subProtocol, WebsocketAcceptedCallback callback) + : this(Guid.NewGuid().ToString("N"), subProtocol, callback) + { } + + internal WebSocketSession(string socketId, string? subProtocol, WebsocketAcceptedCallback callback) + { + SocketID = socketId; + SubProtocol = subProtocol; + //Store the callback function + AcceptedCallback = callback; + } + + /// + /// Initialzes the created websocket with the specified protocol + /// + /// Transport stream to use for the websocket + /// The accept callback function specified during object initialization + protected override async Task RunAsync(Stream transport) + { + try + { + WebSocketCreationOptions ce = new() + { + IsServer = true, + KeepAliveInterval = TimeSpan.FromSeconds(30), + SubProtocol = SubProtocol, + }; + + //Create a new websocket from the context stream + WsHandle = WebSocket.CreateFromStream(transport, ce); + + //Register token to abort the websocket so the managed ws uses the non-fallback send/recv method + using CancellationTokenRegistration abortReg = Token.Register(WsHandle.Abort); + + //Return the callback function to explcitly invoke it + await AcceptedCallback(this); + } + finally + { + WsHandle?.Dispose(); + UserState = null; + } + } + + /// + /// Asynchronously receives data from the Websocket and copies the data to the specified buffer + /// + /// The buffer to store read data + /// A task that resolves a which contains the status of the operation + /// + public Task ReceiveAsync(ArraySegment buffer) + { + //Begin receive operation only with the internal token + return WsHandle!.ReceiveAsync(buffer, CancellationToken.None); + } + + /// + /// Asynchronously receives data from the Websocket and copies the data to the specified buffer + /// + /// The buffer to store read data + /// + /// + public ValueTask ReceiveAsync(Memory buffer) + { + //Begin receive operation only with the internal token + return WsHandle!.ReceiveAsync(buffer, CancellationToken.None); + } + + /// + /// Asynchronously sends the specified buffer to the client of the specified type + /// + /// The buffer containing data to send + /// The message/data type of the packet to send + /// A value that indicates this message is the final message of the transaction + /// + /// + public Task SendAsync(ArraySegment buffer, WebSocketMessageType type, bool endOfMessage) + { + //Create a send request with + return WsHandle!.SendAsync(buffer, type, endOfMessage, CancellationToken.None); + } + + /// + /// Asynchronously sends the specified buffer to the client of the specified type + /// + /// The buffer containing data to send + /// The message/data type of the packet to send + /// A value that indicates this message is the final message of the transaction + /// + /// + public ValueTask SendAsync(ReadOnlyMemory buffer, WebSocketMessageType type, bool endOfMessage) + { + //Begin receive operation only with the internal token + return WsHandle!.SendAsync(buffer, type, endOfMessage, CancellationToken.None); + } + + + /// + /// Properly closes a currently connected websocket + /// + /// Set the close status + /// Set the close reason + /// + public Task CloseSocketAsync(WebSocketCloseStatus status, string reason) + { + return WsHandle!.CloseAsync(status, reason, CancellationToken.None); + } + + /// + /// + /// + /// + /// + /// + /// + public Task CloseSocketOutputAsync(WebSocketCloseStatus status, string reason, CancellationToken cancellation = default) + { + if (WsHandle!.State == WebSocketState.Open || WsHandle.State == WebSocketState.CloseSent) + { + return WsHandle.CloseOutputAsync(status, reason, cancellation); + } + return Task.CompletedTask; + } + } +} \ No newline at end of file -- cgit