diff options
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Auth.Social/src')
18 files changed, 1830 insertions, 0 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientAccessTokenState.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientAccessTokenState.cs new file mode 100644 index 0000000..5ba77f2 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientAccessTokenState.cs @@ -0,0 +1,48 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: OAuthAccessState.cs +* +* OAuthAccessState.cs is part of VNLib.Plugins.Essentials.Auth.Social which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + public class OAuthAccessState : IOAuthAccessState + { + ///<inheritdoc/> + [JsonPropertyName("access_token")] + public string? Token { get; set; } + ///<inheritdoc/> + [JsonPropertyName("scope")] + public string? Scope { get; set; } + ///<inheritdoc/> + [JsonPropertyName("token_type")] + public string? Type { get; set; } + ///<inheritdoc/> + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + ///<inheritdoc/> + [JsonPropertyName("id_token")] + public string? IdToken { get; set; } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientClaimManager.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientClaimManager.cs new file mode 100644 index 0000000..0c4f9ba --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientClaimManager.cs @@ -0,0 +1,126 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: ClientClaimManager.cs +* +* ClientClaimManager.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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 VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Extensions; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + internal sealed record class ClientClaimManager(ICookieController Cookies) + { + const string SESSION_SIG_KEY_NAME = "soa.sig"; + const int SIGNING_KEY_SIZE = 32; + + public bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim) + { + claim = null; + + string? cookieValue = Cookies.GetCookie(entity); + + //Try to get the cookie + if (cookieValue == null) + { + return false; + } + + //Recover the signing key from the user's session + string sigKey = entity.Session[SESSION_SIG_KEY_NAME]; + Span<byte> key = stackalloc byte[SIGNING_KEY_SIZE + 16]; + + ERRNO keySize = VnEncoding.Base64UrlDecode(sigKey, key); + + if (keySize < 1) + { + return false; + } + + try + { + //Try to parse the jwt + using JsonWebToken jwt = JsonWebToken.Parse(cookieValue); + + //Verify the jwt + if (!jwt.Verify(key[..(int)keySize], HashAlg.SHA256)) + { + return false; + } + + //Recover the clam from the jwt + claim = jwt.GetPayload<LoginClaim>(); + + //Verify the expiration time + return claim.ExpirationSeconds > entity.RequestedTimeUtc.ToUnixTimeSeconds(); + } + catch (FormatException) + { + //JWT was corrupted and could not be parsed + return false; + } + finally + { + MemoryUtil.InitializeBlock(key); + } + } + + public void ClearClaimData(HttpEntity entity) + { + //Remove the upgrade cookie + Cookies.ExpireCookie(entity, false); + + //Clear the signing key from the session + entity.Session[SESSION_SIG_KEY_NAME] = null!; + } + + public void SignAndSetCookie(HttpEntity entity, LoginClaim claim) + { + //Setup Jwt + using JsonWebToken jwt = new(); + + //Write claim body, we dont need a header + jwt.WritePayload(claim, Statics.SR_OPTIONS); + + //Generate signing key + byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE); + + //Sign the jwt + jwt.Sign(sigKey, HashAlg.SHA256); + + Cookies.SetCookie(entity, jwt.Compile()); + + //Encode and store the signing key in the clien't session + entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase64UrlSafeString(sigKey, false); + + //Clear the signing key + MemoryUtil.InitializeBlock(sigKey.AsSpan()); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientRequestState.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientRequestState.cs new file mode 100644 index 0000000..ea8eec9 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientRequestState.cs @@ -0,0 +1,81 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: ClientRequestState.cs +* +* ClientRequestState.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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; +using VNLib.Utils.Memory.Caching; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + internal sealed class ClientRequestState : ICacheable + { + private readonly ReadOnlyMemory<byte> _rawKey; + + /// <summary> + /// The raw nonce state bytes + /// </summary> + public ReadOnlyMemory<byte> State { get; private set; } + + public ClientRequestState(ReadOnlySpan<char> keyChar, int nonceBytes) + { + //Get browser id + _rawKey = Convert.FromHexString(keyChar); + RecomputeState(nonceBytes); + } + + /// <summary> + /// Recomputes a nonce state and signature for the current + /// connection + /// </summary> + /// <param name="nonceBytes">The size of the nonce (in bytes) to generate</param> + public void RecomputeState(int nonceBytes) + { + //Get random nonce buffer + State = RandomHash.GetRandomBytes(nonceBytes); + } + /// <summary> + /// Computes the signature of the supplied data based on the original + /// client state for this connection + /// </summary> + /// <param name="data"></param> + /// <returns></returns> + public ERRNO ComputeSignatureForClient(ReadOnlySpan<byte> data, Span<byte> output) + { + return HMACSHA512.TryHashData(_rawKey.Span, data, output, out int count) ? count : ERRNO.E_FAIL; + } + + public DateTime Expires { get; set; } + bool IEquatable<ICacheable>.Equals(ICacheable other) => ReferenceEquals(this, other); + void ICacheable.Evicted() + { + //Zero secrets on eviction + MemoryUtil.UnsafeZeroMemory(State); + MemoryUtil.UnsafeZeroMemory(_rawKey); + } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/GetTokenRequest.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/GetTokenRequest.cs new file mode 100644 index 0000000..d061937 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/GetTokenRequest.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: GetTokenRequest.cs +* +* GetTokenRequest.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// <summary> + /// A request message to get an OAuth2 token from a code + /// </summary> + /// <param name="Code">The clients authentication code</param> + /// <param name="RedirectUrl">The redirect url for current site</param> + public sealed record class GetTokenRequest(string Code, string RedirectUrl) + { } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthAccessState.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthAccessState.cs new file mode 100644 index 0000000..cbdd41a --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthAccessState.cs @@ -0,0 +1,55 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: IOAuthAccessState.cs +* +* IOAuthAccessState.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// <summary> + /// An object that represents an OAuth2 access token in its + /// standard form. + /// </summary> + public interface IOAuthAccessState + { + /// <summary> + /// The OAuth2 access token + /// </summary> + public string? Token { get; set; } + /// <summary> + /// Token grant scope + /// </summary> + string? Scope { get; set; } + /// <summary> + /// The OAuth2 token type, usually 'Bearer' + /// </summary> + string? Type { get; set; } + /// <summary> + /// Optional refresh token + /// </summary> + string? RefreshToken { get; set; } + + /// <summary> + /// Optional ID OIDC token + /// </summary> + string? IdToken { get; set; } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthProvider.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthProvider.cs new file mode 100644 index 0000000..6968e05 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthProvider.cs @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: IOAuthProvider.cs +* +* IOAuthProvider.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// <summary> + /// Represents a dynamically loaded social login provider + /// that exposes a set of social login portals + /// </summary> + public interface IOAuthProvider + { + /// <summary> + /// Gets all exported social login portals to be advertised + /// to clients + /// </summary> + /// <returns>The portal array</returns> + SocialOAuthPortal[] GetPortals(); + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginClaim.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginClaim.cs new file mode 100644 index 0000000..70acff0 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginClaim.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: LoginClaim.cs +* +* LoginClaim.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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; + +using VNLib.Hashing; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + internal sealed class LoginClaim : IClientSecInfo + { + [JsonPropertyName("exp")] + public long ExpirationSeconds { get; set; } + + [JsonPropertyName("iat")] + public long IssuedAtTime { get; set; } + + [JsonPropertyName("nonce")] + public string? Nonce { get; set; } + + [JsonPropertyName("locallanguage")] + public string? LocalLanguage { get; set; } + + [JsonPropertyName("pubkey")] + public string? PublicKey { get; set; } + + [JsonPropertyName("clientid")] + public string? ClientId { get; set; } + + + public void ComputeNonce(int nonceSize) + { + //Alloc nonce buffer + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc(nonceSize); + try + { + //fill the buffer with random data + RandomHash.GetRandomBytes(buffer.Span); + + //Base32-Encode nonce and save it + Nonce = VnEncoding.ToBase64UrlSafeString(buffer.Span, false); + } + finally + { + MemoryUtil.InitializeBlock(buffer.Span); + } + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginUriBuilder.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginUriBuilder.cs new file mode 100644 index 0000000..da37fb7 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginUriBuilder.cs @@ -0,0 +1,127 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: LoginUriBuilder.cs +* +* LoginUriBuilder.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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 System.Runtime.InteropServices; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + /* + * Construct the client's redirect url based on their login claim, which contains + * a public key which can be used to encrypt the url so that only the client + * private-key holder can decrypt the url and redirect themselves to the + * target OAuth website. + * + * The result is an encrypted nonce that should guard against replay attacks and MITM + */ + + internal sealed record class LoginUriBuilder(OauthClientConfig Config) + { + private string? redirectUrl; + private string? nonce; + private Encoding _encoding = Encoding.UTF8; + + public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path) + { + //Alloc stack buffer for url + Span<char> buffer = stackalloc char[1024]; + + //buffer writer for easier syntax + ForwardOnlyWriter<char> writer = new(buffer); + //first build the redirect url to re-encode it + writer.Append(scheme); + writer.Append("://"); + //Create redirect url (current page, default action is to authorize the client) + writer.Append(authority); + writer.Append(path); + //url encode the redirect path and save it for later + redirectUrl = Uri.EscapeDataString(writer.ToString()); + + return this; + } + + public LoginUriBuilder WithEncoding(Encoding encoding) + { + _encoding = encoding; + return this; + } + + public LoginUriBuilder WithNonce(string base32Nonce) + { + nonce = base32Nonce; + return this; + } + + public string Encrypt(HttpEntity client, IClientSecInfo secInfo) + { + //Alloc buffer and split it into binary and char buffers + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(8000); + + Span<byte> binBuffer = buffer.Span[2048..]; + Span<char> charBuffer = MemoryMarshal.Cast<byte, char>(buffer.Span[..2048]); + + + /* + * Build the character uri so we can encode it to binary, + * encrypt it and return it to the client + */ + + ForwardOnlyWriter<char> writer = new(charBuffer); + + //Append the config redirect path + writer.Append(Config.AccessCodeUrl.OriginalString); + //begin query arguments + writer.Append("&client_id="); + writer.Append(Config.ClientID.Value); + //add the redirect url + writer.Append("&redirect_uri="); + writer.Append(redirectUrl); + //Append the state parameter + writer.Append("&state="); + writer.Append(nonce); + + //Collect the written character data + ReadOnlySpan<char> url = writer.AsSpan(); + + //Separate bin buffers for encryption and encoding + Span<byte> encryptionBuffer = binBuffer[1024..]; + Span<byte> encodingBuffer = binBuffer[..1024]; + + //Encode the url to binary + int byteCount = _encoding.GetBytes(url, encodingBuffer); + + //Encrypt the binary data + ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer); + + //base64 encode the encrypted + return Convert.ToBase64String(encryptionBuffer[0..(int)count]); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OAuthSiteAdapter.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OAuthSiteAdapter.cs new file mode 100644 index 0000000..37dd7e0 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OAuthSiteAdapter.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: OAuthSiteAdapter.cs +* +* OAuthSiteAdapter.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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 System.Threading; +using System.Threading.Tasks; + +using RestSharp; + +using VNLib.Net.Rest.Client; +using VNLib.Net.Rest.Client.Construction; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + /// <summary> + /// Provides strucutred http messaging to an OAuth2 site. + /// </summary> + public sealed class OAuthSiteAdapter : RestSiteAdapterBase + { + ///<inheritdoc/> + protected override RestClientPool Pool { get; } + + /// <summary> + /// Initializes a new instance of the <see cref="OAuthSiteAdapter"/> class. + /// </summary> + public OAuthSiteAdapter() + { + RestClientOptions poolOptions = new() + { + MaxTimeout = 5000, + AutomaticDecompression = DecompressionMethods.All, + Encoding = Encoding.UTF8, + //disable redirects, api should not redirect + FollowRedirects = false, + }; + + //Configure rest client to comunications to main discord api + Pool = new(10, poolOptions); + } + + ///<inheritdoc/> + public override void OnResponse(RestResponse response) + { } + + ///<inheritdoc/> + public override Task WaitAsync(CancellationToken cancellation = default) + { + return Task.CompletedTask; + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OauthClientConfig.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OauthClientConfig.cs new file mode 100644 index 0000000..a3b43ad --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OauthClientConfig.cs @@ -0,0 +1,148 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: OauthClientConfig.cs +* +* OauthClientConfig.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Collections.Generic; + +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Extensions.Loading; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + + /// <summary> + /// Contains the standard configuration data for an OAuth2 endpoint + /// defined by plugin configuration + /// </summary> + public sealed class OauthClientConfig + { + + public OauthClientConfig(PluginBase plugin, IConfigScope config) + { + EndpointPath = config["path"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'path' in config {config.ScopeName}"); + + //Set discord account origin + AccountOrigin = config["account_origin"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'account_origin' in config {config.ScopeName}"); + + //Get the auth and token urls + string authUrl = config["authorization_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'authorization_url' in config {config.ScopeName}"); + string tokenUrl = config["token_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'token_url' in config {config.ScopeName}"); + string userUrl = config["user_data_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'user_data_url' in config {config.ScopeName}"); + //Create the uris + AccessCodeUrl = new(authUrl); + AccessTokenUrl = new(tokenUrl); + UserDataUrl = new(userUrl); + + AllowForLocalAccounts = config["allow_for_local"].GetBoolean(); + AllowRegistration = config["allow_registration"].GetBoolean(); + NonceByteSize = config["nonce_size"].GetUInt32(); + RandomPasswordSize = config["password_size"].GetInt32(); + InitClaimValidFor = config["claim_valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); + + //Setup async lazy loaders for secrets + ClientID = plugin.GetSecretAsync($"{config.ScopeName}_client_id") + .ToLazy(static r => r.Result.ToString()); + + ClientSecret = plugin.GetSecretAsync($"{config.ScopeName}_client_secret") + .ToLazy(static r => r.Result.ToString()); + + //Log the token server ip address for the user to verify + if (plugin.Log.IsEnabled(LogLevel.Verbose)) + { + _ = plugin.ObserveWork(async () => + { + IPAddress[] addresses = await Dns.GetHostAddressesAsync(AccessTokenUrl.DnsSafeHost); + plugin.Log.Verbose("Token server {host} resolves to {ip}", AccessTokenUrl.DnsSafeHost, addresses); + }); + } + } + + /// <summary> + /// The client ID for the OAuth2 service + /// </summary> + public IAsyncLazy<string> ClientID { get; } + + /// <summary> + /// The client secret for the OAuth2 service + /// </summary> + public IAsyncLazy<string> ClientSecret { get; } + + + /// <summary> + /// The user-account origin value. Specifies that the user account + /// was created outside of the local account system + /// </summary> + public string AccountOrigin { get; } + + /// <summary> + /// The URL to redirect the user to the OAuth2 service + /// to begin the authentication process + /// </summary> + public Uri AccessCodeUrl { get; } + + /// <summary> + /// The remote endoint to exchange codes for access tokens + /// </summary> + public Uri AccessTokenUrl { get; } + + /// <summary> + /// The endpoint to get user-data object from + /// </summary> + public Uri UserDataUrl { get; } + + /// <summary> + /// The endpoint route/path + /// </summary> + public string EndpointPath { get; } + + /// <summary> + /// The size (in bytes) of the random generated nonce + /// </summary> + public uint NonceByteSize { get; } + + /// <summary> + /// A value that specifies if locally created accounts are allowed + /// to be logged in from an OAuth2 source + /// </summary> + public bool AllowForLocalAccounts { get; } + + /// <summary> + /// A value that indicates if accounts that do not exist will be created + /// and logged in immediatly, on successfull OAuth2 flow + /// </summary> + public bool AllowRegistration { get; } + + /// <summary> + /// The size (in bytes) of the random password generated for new users + /// </summary> + public int RandomPasswordSize { get; } + + /// <summary> + /// The initial time the login claim is valid for + /// </summary> + public TimeSpan InitClaimValidFor { get; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs new file mode 100644 index 0000000..4e5f867 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs @@ -0,0 +1,75 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: PortalsEndpoint.cs +* +* PortalsEndpoint.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Linq; +using System.Collections.Generic; + +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Extensions.Loading; + + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + [ConfigurationName("portals")] + internal sealed class PortalsEndpoint : UnprotectedWebEndpoint + { + private PortalDefJson[] _portals; + + public PortalsEndpoint(PluginBase plugin, IConfigScope config) + { + string path = config.GetRequiredProperty("path", p => p.GetString()!); + InitPathAndLog(path, plugin.Log); + + //Empty array by default + _portals = []; + } + + public void SetPortals(IEnumerable<SocialOAuthPortal> portals) + { + //Convert to json + _portals = portals.Select(p => new PortalDefJson + { + id = p.PortalId, + login = p.LoginEndpoint.Path, + logout = p.LogoutEndpoint?.Path, + }).ToArray(); + } + + protected override VfReturnType Get(HttpEntity entity) + { + //return portals array as json + return VirtualOkJson(entity, _portals); + } + + private sealed class PortalDefJson + { + public string? id { get; set; } + + public string? login { get; set; } + + public string? logout { get; set; } + } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialAuthEntry.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialAuthEntry.cs new file mode 100644 index 0000000..397d688 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialAuthEntry.cs @@ -0,0 +1,99 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: SocialAuthEntry.cs +* +* SocialAuthEntry.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Linq; +using System.Text.Json; +using System.Collections.Generic; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Routing; + + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + + public sealed class SocialAuthEntry : PluginBase + { + const string ProviderConfigKey = "providers"; + + ///<inheritdoc/> + public override string PluginName => "Auth.Socal"; + + ///<inheritdoc/> + protected override void OnLoad() + { + Log.Information("Loading social authentication providers"); + + //Get provider array + if(PluginConfig.TryGetProperty(ProviderConfigKey, out JsonElement providerArray)) + { + //Get dll file names + string[] providerDlls = providerArray.EnumerateArray() + .Select(e => e.GetString()!) + .ToArray(); + + List<SocialOAuthPortal> portals = new(); + + /* + * Using the loading library to create the exported services + * which are IOAuthProvider implementations + */ + foreach (string dll in providerDlls) + { + //Load the dll + IOAuthProvider provider = this.CreateServiceExternal<IOAuthProvider>(dll); + + //Capture all portals + portals.AddRange(provider.GetPortals()); + + Log.Information($"Loaded OAuth method {provider.GetType().Name}"); + } + + //Define portals endpoint and set portals + PortalsEndpoint p = this.Route<PortalsEndpoint>(); + p.SetPortals(portals); + } + else + { + Log.Warn("No providers array defined in config"); + } + } + + ///<inheritdoc/> + protected override void OnUnLoad() + { + Log.Information("Plugin unloaded"); + } + + ///<inheritdoc/> + protected override void ProcessHostCommand(string cmd) + { + throw new NotImplementedException(); + } + + + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs new file mode 100644 index 0000000..3fe6ddf --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: SocialOAuthPortal.cs +* +* SocialOAuthPortal.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// <summary> + /// Defines a single oauth social login portal + /// </summary> + /// <param name="PortalId"> The unique identifier for the portal </param> + /// <param name="LoginEndpoint"> Required login endpoint to advertise to the client </param> + /// <param name="LogoutEndpoint"> Optional logout endpoint to advertise to the client </param> + public record SocialOAuthPortal(string PortalId, IEndpoint LoginEndpoint, IEndpoint? LogoutEndpoint); +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOauthBase.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOauthBase.cs new file mode 100644 index 0000000..52da637 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOauthBase.cs @@ -0,0 +1,572 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: SocialOauthBase.cs +* +* SocialOauthBase.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Security.Cryptography; +using System.Text.Json.Serialization; + +using FluentValidation; + +using RestSharp; + +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Net.Rest.Client.Construction; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Auth.Social.Validators; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Extensions.Loading.Users; + +using ContentType = VNLib.Net.Http.ContentType; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + + /// <summary> + /// Provides a base class for derriving commong OAuth2 implicit authentication + /// </summary> + public abstract class SocialOauthBase : UnprotectedWebEndpoint + { + const string AUTH_ERROR_MESSAGE = "You have no pending authentication requests."; + + const string AUTH_GRANT_SESSION_NAME = "auth"; + const string SESSION_TOKEN_KEY_NAME = "soa.tkn"; + const string CLAIM_COOKIE_NAME = "extern-claim"; + + + /// <summary> + /// The client configuration struct passed during base class construction + /// </summary> + protected virtual OauthClientConfig Config { get; } + + ///<inheritdoc/> + protected override ProtectionSettings EndpointProtectionSettings { get; } + + /// <summary> + /// The site adapter used to make requests to the OAuth2 provider + /// </summary> + protected OAuthSiteAdapter SiteAdapter { get; } + + /// <summary> + /// The user manager used to create and manage user accounts + /// </summary> + protected IUserManager Users { get; } + + private readonly IValidator<LoginClaim> ClaimValidator; + private readonly IValidator<string> NonceValidator; + private readonly IValidator<AccountData> AccountDataValidator; + private readonly ClientClaimManager _claims; + + protected SocialOauthBase(PluginBase plugin, IConfigScope config) + { + ClaimValidator = GetClaimValidator(); + NonceValidator = GetNonceValidator(); + AccountDataValidator = new AccountDataValidator(); + + //Get the configuration element for the derrived type + Config = plugin.CreateService<OauthClientConfig>(config); + + //Init endpoint + InitPathAndLog(Config.EndpointPath, plugin.Log); + + Users = plugin.GetOrCreateSingleton<UserManager>(); + + + //Setup cookie controller and claim manager + SingleCookieController cookies = new(CLAIM_COOKIE_NAME, Config.InitClaimValidFor) + { + Secure = true, + HttpOnly = true, + SameSite = CookieSameSite.None, + Path = Path + }; + + _claims = new(cookies); + + //Define the site adapter + SiteAdapter = new(); + + //Define the the get-token request endpoint + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint<GetTokenRequest>() + .WithMethod(Method.Post) + .WithUrl(Config.AccessTokenUrl) + .WithHeader("Accept", HttpHelpers.GetContentTypeString(ContentType.Json)) + .WithParameter("client_id", c => Config.ClientID.Value) + .WithParameter("client_secret", c => Config.ClientSecret.Value) + .WithParameter("grant_type", "authorization_code") + .WithParameter("code", r => r.Code) + .WithParameter("redirect_uri", r => r.RedirectUrl); + } + + private static IValidator<LoginClaim> GetClaimValidator() + { + InlineValidator<LoginClaim> val = new(); + val.RuleFor(static s => s.ClientId) + .Length(10, 100) + .WithMessage("Request is not valid") + .AlphaNumericOnly() + .WithMessage("Request is not valid"); + + val.RuleFor(static s => s.PublicKey) + .Length(50, 1024) + .WithMessage("Request is not valid"); + + val.RuleFor(static s => s.LocalLanguage) + .Length(2, 10) + .WithMessage("Request is not valid"); + + return val; + } + + private static IValidator<string> GetNonceValidator() + { + InlineValidator<string> val = new(); + val.RuleFor(static s => s) + .Length(10, 200) + //Nonces are base32, so only alpha num + .AlphaNumeric(); + return val; + } + + ///<inheritdoc/> + protected override ERRNO PreProccess(HttpEntity entity) + { + if (!base.PreProccess(entity)) + { + return false; + } + + /* + * Cross site checking is disabled because we need to allow cross site + * for OAuth2 redirect flows + */ + if (entity.Server.Method != HttpMethod.GET && entity.Server.IsCrossSite()) + { + return false; + } + + //Make sure the user is not logged in + return !entity.IsClientAuthorized(AuthorzationCheckLevel.Any); + } + + /// <summary> + /// When derrived in a child class, exchanges an OAuth2 code grant type + /// for an OAuth2 access token to make api requests + /// </summary> + /// <param name="ev"></param> + /// <param name="code">The raw code from the remote OAuth2 granting server</param> + /// <param name="cancellationToken">A token to cancel the operation</param> + /// <returns> + /// A task the resolves the <see cref="OAuthAccessState"/> that includes all relavent + /// authorization data. Result may be null if authorzation is invalid or not granted + /// </returns> + protected virtual async Task<OAuthAccessState?> ExchangeCodeForTokenAsync(HttpEntity ev, string code, CancellationToken cancellationToken) + { + //Create new request object + GetTokenRequest req = new(code, $"{ev.Server.RequestUri.Scheme}://{ev.Server.RequestUri.Authority}{Path}"); + + //Execute request and attempt to recover the authorization response + Oauth2TokenResult? response = await SiteAdapter.ExecuteAsync(req, cancellationToken).AsJson<Oauth2TokenResult>(); + + if(response?.Error != null) + { + Log.Debug("Error result from {conf} code {code} description: {err}", Config.AccountOrigin, response.Error, response.ErrorDescription); + return null; + } + + return response; + } + + /// <summary> + /// Gets an object that represents the user's account data from the OAuth provider when + /// creating a new user for the current platform + /// </summary> + /// <param name="clientAccess">The access state from the code/token exchange</param> + /// <param name="cancellationToken">A token to cancel the operation</param> + /// <returns>The user's account data, null if not account exsits on the remote site, and process cannot continue</returns> + protected abstract Task<AccountData?> GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken); + + /// <summary> + /// Gets an object that represents the required information for logging-in a user (namley unique user-id) + /// </summary> + /// <param name="clientAccess">The authorization information granted from the OAuth2 authorization server</param> + /// <param name="cancellation">A token to cancel the operation</param> + /// <returns></returns> + protected abstract Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation); + + + + /* + * Claims are considered indempodent because they require no previous state + * and will return a new secret authentication "token" (url + nonce) that + * uniquely identifies the claim and authorization upgrade later + */ + + ///<inheritdoc/> + protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Get the login message + LoginClaim? claim = await entity.GetJsonFromFileAsync<LoginClaim>(); + + if (webm.Assert(claim != null, "Emtpy message body")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate the message + if (!ClaimValidator.Validate(claim, webm)) + { + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + + //Configure the login claim + claim.IssuedAtTime = entity.RequestedTimeUtc.ToUnixTimeSeconds(); + + //Set expiration time in seconds + claim.ExpirationSeconds = entity.RequestedTimeUtc.Add(Config.InitClaimValidFor).ToUnixTimeMilliseconds(); + + //Set nonce + claim.ComputeNonce((int)Config.NonceByteSize); + + //Build the redirect uri + webm.Result = new LoginUriBuilder(Config) + .WithEncoding(entity.Server.Encoding) + .WithUrl(entity.Server.RequestUri.Scheme, entity.Server.RequestUri.Authority, Path) + .WithNonce(claim.Nonce!) + .Encrypt(entity, claim); + + //Sign and set the claim cookie + _claims.SignAndSetCookie(entity, claim); + + webm.Success = true; + //Response + return VirtualOk(entity, webm); + } + + /* + * Get method is invoked when the remote OAuth2 control has been passed back + * to this server. If successful should include a code that grants authorization + * and include a state variable that the client decrypted from an initial claim + * to prove its identity + */ + + ///<inheritdoc/> + protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity) + { + //Make sure state and code parameters are available + if (entity.QueryArgs.TryGetNonEmptyValue("state", out string? state) + && entity.QueryArgs.TryGetNonEmptyValue("code", out string? code)) + { + //Disable refer headers when nonce is set + entity.Server.Headers["Referrer-Policy"] = "no-referrer"; + + //Check for security navigation headers. This should be a browser redirect, + if (!entity.Server.IsNavigation() || !entity.Server.IsUserInvoked()) + { + _claims.ClearClaimData(entity); + //The connection was not a browser redirect + entity.Redirect(RedirectType.Temporary, $"{Path}?result=bad_sec"); + return VfReturnType.VirtualSkip; + } + + //Try to get the claim from the state parameter + if (!_claims.VerifyAndGetClaim(entity, out LoginClaim? claim)) + { + _claims.ClearClaimData(entity); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=expired"); + return VfReturnType.VirtualSkip; + } + + //Confirm the nonce matches the claim + if (string.CompareOrdinal(claim.Nonce, state) != 0) + { + _claims.ClearClaimData(entity); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); + return VfReturnType.VirtualSkip; + } + + //Exchange the OAuth code for a token (application specific) + OAuthAccessState? token = await ExchangeCodeForTokenAsync(entity, code, entity.EventCancellation); + + //Token may be null + if (token == null) + { + _claims.ClearClaimData(entity); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); + return VfReturnType.VirtualSkip; + } + + //Create the new nonce + claim.ComputeNonce((int)Config.NonceByteSize); + + //Store access state in the user's session + entity.Session.SetObject(SESSION_TOKEN_KEY_NAME, token); + + //Sign and set cookie + _claims.SignAndSetCookie(entity, claim); + + //Prepare redirect + entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={claim.Nonce}"); + return VfReturnType.VirtualSkip; + } + + //Check to see if there was an error code set + if (entity.QueryArgs.TryGetNonEmptyValue("error", out string? errorCode)) + { + _claims.ClearClaimData(entity); + Log.Debug("{Type} error {err}:{des}", Config.AccountOrigin, errorCode, entity.QueryArgs["error_description"]); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=error"); + return VfReturnType.VirtualSkip; + } + + return VfReturnType.ProcessAsFile; + } + + /* + * Post messages finalize a login from a nonce + */ + + ///<inheritdoc/> + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Get the finalization message + using JsonDocument? request = await entity.GetJsonFromFileAsync(); + + if (webm.Assert(request != null, "Request message is required")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + //Recover the nonce + string? base32Nonce = request.RootElement.GetPropString("nonce"); + + if(webm.Assert(base32Nonce != null, "Nonce parameter is required")) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + + //Validate nonce + if (!NonceValidator.Validate(base32Nonce, webm)) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + + //Recover the access token + if (webm.Assert(_claims.VerifyAndGetClaim(entity, out LoginClaim? claim), AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //We can clear the client's access claim + _claims.ClearClaimData(entity); + + //Confirm nonce matches the client's nonce string + bool nonceValid = string.CompareOrdinal(claim.Nonce, base32Nonce) == 0; + + if (webm.Assert(nonceValid, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Safe to recover the access token + IOAuthAccessState token = entity.Session.GetObject<OAuthAccessState>(SESSION_TOKEN_KEY_NAME); + + //get the user's login information (ie userid) + UserLoginData? userLogin = await GetLoginDataAsync(token, entity.EventCancellation); + + if(webm.Assert(userLogin?.UserId != null, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Convert the platform user-id to a database-safe user-id + string computedId = Users.ComputeSafeUserId(userLogin.UserId!); + + //Fetch the user from the database + IUser? user = await Users.GetUserFromIDAsync(computedId, entity.EventCancellation); + + /* + * If a user is not found, we can optionally create a new user account + * if the configuration allows it. + */ + if(user == null) + { + //make sure registration is enabled + if (webm.Assert(Config.AllowRegistration, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Get the clients personal info to being login process + AccountData? userAccount = await GetAccountDataAsync(token, entity.EventCancellation); + + if (webm.Assert(userAccount != null, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Validate the account data + if (webm.Assert(AccountDataValidator.Validate(userAccount).IsValid, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //See if user by email address exists + user = await Users.GetUserFromEmailAsync(userAccount.EmailAddress!, entity.EventCancellation); + + if (user == null) + { + //Create the new user account + UserCreationRequest creation = new() + { + EmailAddress = userAccount.EmailAddress!, + InitialStatus = UserStatus.Active, + }; + + try + { + //Create the user with the specified email address, minimum privilage level, and an empty password + user = await Users.CreateUserAsync(creation, computedId, entity.EventCancellation); + + //Store the new profile and origin + user.SetProfile(userAccount); + user.SetAccountOrigin(Config.AccountOrigin); + } + catch (UserCreationFailedException) + { + Log.Warn("Failed to create new user from new OAuth2 login, because a creation exception occured"); + webm.Result = "Please try again later"; + return VirtualOk(entity, webm); + } + + //Skip check since we just created the user + goto Authorize; + } + + /* + * User account already exists via email address but not + * user-id + */ + } + + //Make sure local accounts are allowed + if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Reactivate inactive accounts + if (user.Status == UserStatus.Inactive) + { + user.Status = UserStatus.Active; + } + + //Make sure the account is active + if (webm.Assert(user.Status == UserStatus.Active, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + Authorize: + + try + { + //Generate authoization + entity.GenerateAuthorization(claim, user, webm); + + //Store the user current oauth information in the current session for others to digest + entity.Session.SetObject($"{Config.AccountOrigin}.{AUTH_GRANT_SESSION_NAME}", token); + + //Send the username back to the client + webm.Result = new AccountData() + { + EmailAddress = user.EmailAddress, + }; + + //Set the success flag + webm.Success = true; + + //Write to log + Log.Debug("Successful social login for user {uid}... from {ip}", user.UserID[..8], entity.TrustedRemoteIp); + + //release the user + await user.ReleaseAsync(); + } + catch (CryptographicException ce) + { + Log.Debug("Failed to generate authorization for {user}, error {err}", user.UserID, ce.Message); + webm.Result = AUTH_ERROR_MESSAGE; + } + catch (OutOfMemoryException) + { + Log.Debug("Out of buffer space for token data encryption, for user {usr}, from ip {ip}", user.UserID, entity.TrustedRemoteIp); + webm.Result = AUTH_ERROR_MESSAGE; + } + catch(UserUpdateException uue) + { + webm.Token = null; + webm.Result = AUTH_ERROR_MESSAGE; + webm.Success = false; + + //destroy any login data on failure + entity.InvalidateLogin(); + + Log.Error("Failed to update the user's account cause:\n{err}",uue); + } + finally + { + user.Dispose(); + } + return VirtualOk(entity, webm); + } + + + sealed class Oauth2TokenResult: OAuthAccessState + { + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + } + + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/UserLoginData.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/UserLoginData.cs new file mode 100644 index 0000000..7451539 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/UserLoginData.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: UserLoginData.cs +* +* UserLoginData.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + public class UserLoginData + { + [JsonPropertyName("user_id")] + public string? UserId { get; set; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/VNLib.Plugins.Essentials.Auth.Social.csproj b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/VNLib.Plugins.Essentials.Auth.Social.csproj new file mode 100644 index 0000000..dd6132c --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/VNLib.Plugins.Essentials.Auth.Social.csproj @@ -0,0 +1,63 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <Nullable>enable</Nullable> + <TargetFramework>net8.0</TargetFramework> + <RootNamespace>VNLib.Plugins.Essentials.Auth.Social</RootNamespace> + <AssemblyName>Auth.Social</AssemblyName> + <GenerateDocumentationFile>True</GenerateDocumentationFile> + <AnalysisLevel>latest-all</AnalysisLevel> + <NeutralLanguage>en-US</NeutralLanguage> + <EnableDynamicLoading>true</EnableDynamicLoading> + </PropertyGroup> + + <PropertyGroup> + <PackageId>VNLib.Plugins.Essentials.Auth.Social</PackageId> + <Authors>Vaughn Nugent</Authors> + <Company>Vaughn Nugent</Company> + <Product>A shared library for building social OAuth authentication endpoints.</Product> + <Copyright>Copyright © 2024 Vaughn Nugent</Copyright> + <PackageProjectUrl>https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials</PackageProjectUrl> + <RepositoryUrl>https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/VNLib.Plugins.Essentials.Auth.Social</RepositoryUrl> + <Description>Essentials framework plugin that loads dynamic providers like my packaged GitHub, Discord, and Auth0 providers. This package also contains abstractions for building other providers. and may be inlcuded as a library to build a new provider</Description> + </PropertyGroup> + + <PropertyGroup> + <PackageReadmeFile>README.md</PackageReadmeFile> + <PackageLicenseFile>LICENSE</PackageLicenseFile> + </PropertyGroup> + <ItemGroup> + <None Include="..\..\..\LICENSE"> + <Pack>True</Pack> + <PackagePath>\</PackagePath> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <None Include="..\README.md"> + <Pack>True</Pack> + <PackagePath>\</PackagePath> + </None> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\..\core\lib\Net.Rest.Client\src\VNLib.Net.Rest.Client.csproj" /> + <ProjectReference Include="..\..\..\..\..\core\lib\Plugins.Essentials\src\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\..\..\Extensions\lib\VNLib.Plugins.Extensions.Loading\src\VNLib.Plugins.Extensions.Loading.csproj" /> + <ProjectReference Include="..\..\..\..\Extensions\lib\VNLib.Plugins.Extensions.Validation\src\VNLib.Plugins.Extensions.Validation.csproj" /> + </ItemGroup> + + <Target Condition="'$(BuildingInsideVisualStudio)' == true" Name="PostBuild" AfterTargets="PostBuildEvent"> + <Exec Command="start xcopy "$(TargetDir)" "$(SolutionDir)devplugins\$(TargetName)" /E /Y /R" /> + </Target> + +</Project> diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/AccountDataValidator.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/AccountDataValidator.cs new file mode 100644 index 0000000..269a9f3 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/AccountDataValidator.cs @@ -0,0 +1,83 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: AccountDataValidator.cs +* +* AccountDataValidator.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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 FluentValidation; + +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Extensions.Validation; + +namespace VNLib.Plugins.Essentials.Auth.Social.Validators +{ + internal class AccountDataValidator : AbstractValidator<AccountData> + { + public AccountDataValidator() : base() + { + RuleFor(t => t.EmailAddress) + .NotEmpty() + .WithMessage("Your account does not have an email address assigned to it"); + + RuleFor(t => t.City) + .MaximumLength(35) + .AlphaOnly() + .When(t => t.City?.Length > 0); + + RuleFor(t => t.Company) + .MaximumLength(50) + .SpecialCharacters() + .When(t => t.Company?.Length > 0); + + //Require a first and last names to be set together + When(t => t.First?.Length > 0 || t.Last?.Length > 0, () => + { + RuleFor(t => t.First) + .Length(1, 35) + .AlphaOnly(); + RuleFor(t => t.Last) + .Length(1, 35) + .AlphaOnly(); + }); + + RuleFor(t => t.PhoneNumber) + .PhoneNumber() + .When(t => t.PhoneNumber?.Length > 0) + .OverridePropertyName("Phone"); + + //State must be 2 characters for us states if set + RuleFor(t => t.State) + .Length(2) + .When(t => t.State?.Length > 0); + + RuleFor(t => t.Street) + .AlphaNumericOnly() + .MaximumLength(50) + .When(t => t.Street?.Length > 0); + + //Allow empty zip codes, but if one is defined, is must be less than 7 characters + RuleFor(t => t.Zip) + .NumericOnly() + .MaximumLength(7) + .When(t => t.Zip?.Length > 0); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/LoginMessageValidation.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/LoginMessageValidation.cs new file mode 100644 index 0000000..f3894c9 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/LoginMessageValidation.cs @@ -0,0 +1,65 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: LoginMessageValidation.cs +* +* LoginMessageValidation.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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 FluentValidation; + +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Extensions.Validation; + +namespace VNLib.Plugins.Essentials.Auth.Social.Validators +{ + internal class LoginMessageValidation : AbstractValidator<LoginMessage> + { + /* + * A login message object is only used for common semantics within + * the user-system so validation operations are different than a + * normal login endpoint as named fields may be used differently + */ + public LoginMessageValidation() + { + RuleFor(t => t.ClientId) + .Length(10, 50) + .WithMessage("Your browser is not sending required security information") + .IllegalCharacters() + .WithMessage("Your browser is not sending required security information"); + + RuleFor(t => t.ClientPublicKey) + .Length (50, 1000) + .WithMessage("Your browser is not sending required security information") + .IllegalCharacters() + .WithMessage("Your browser is not sending required security information"); + + //Password is only used for nonce tokens + RuleFor(t => t.Password).NotEmpty(); + + RuleFor(t => t.LocalLanguage) + .NotEmpty() + .WithMessage("Your language is not supported") + .AlphaNumericOnly() + .WithMessage("Your language is not supported"); + } + } +} |