From 551066ed9a255bd47c1c5789ec1998fda64bd5aa Mon Sep 17 00:00:00 2001 From: vnugent Date: Thu, 12 Jan 2023 17:47:40 -0500 Subject: Large project reorder and consolidation --- .../ClientAccessTokenState.cs | 85 --- .../ClientRequestState.cs | 81 --- .../Endpoints/Auth0.cs | 195 ------- .../Endpoints/DiscordOauth.cs | 158 ------ .../Endpoints/GitHubOauth.cs | 219 -------- .../IOAuthAccessState.cs | 57 -- .../OauthClientConfig.cs | 126 ----- VNLib.Plugins.Essentials.SocialOauth/README.md | 45 -- .../SocialEntryPoint.cs | 82 --- .../SocialOauthBase.cs | 617 --------------------- .../UserLoginData.cs | 36 -- .../VNLib.Plugins.Essentials.SocialOauth.csproj | 54 -- .../Validators/AccountDataValidator.cs | 74 --- .../Validators/LoginMessageValidation.cs | 60 -- 14 files changed, 1889 deletions(-) delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/README.md delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs delete mode 100644 VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs (limited to 'VNLib.Plugins.Essentials.SocialOauth') diff --git a/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs b/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs deleted file mode 100644 index e5de597..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs +++ /dev/null @@ -1,85 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: ClientAccessTokenState.cs -* -* ClientAccessTokenState.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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 System.Text.Json.Serialization; - -using VNLib.Hashing; -using VNLib.Utils.Memory; -using VNLib.Utils.Memory.Caching; -using VNLib.Plugins.Essentials.Accounts; - -namespace VNLib.Plugins.Essentials.SocialOauth -{ - public sealed class OAuthAccessState : IOAuthAccessState, ICacheable, INonce - { - /// - [JsonPropertyName("access_token")] - public string? Token { get; set; } - /// - [JsonPropertyName("scope")] - public string? Scope { get; set; } - /// - [JsonPropertyName("token_type")] - public string? Type { get; set; } - /// - [JsonPropertyName("refresh_token")] - public string? RefreshToken { get; set; } - /// - [JsonPropertyName("id_token")] - public string? IdToken { get; set; } - - //Ignore the public key and client ids - [JsonIgnore] - internal string? PublicKey { get; set; } - [JsonIgnore] - internal string? ClientId { get; set; } - - /// - /// A random nonce generated when the access state is created and - /// deleted when then access token is evicted. - /// - [JsonIgnore] - internal ReadOnlyMemory Nonce { get; private set; } - - DateTime ICacheable.Expires { get; set; } - bool IEquatable.Equals(ICacheable? other) => GetHashCode() == other?.GetHashCode(); - public override int GetHashCode() => Token!.GetHashCode(StringComparison.Ordinal); - void ICacheable.Evicted() - { - Memory.UnsafeZeroMemory(Nonce); - } - - void INonce.ComputeNonce(Span buffer) - { - //Compute nonce - RandomHash.GetRandomBytes(buffer); - //Copy and store - Nonce = buffer.ToArray(); - } - - bool INonce.VerifyNonce(ReadOnlySpan nonceBytes) => CryptographicOperations.FixedTimeEquals(Nonce.Span, nonceBytes); - } -} \ No newline at end of file diff --git a/VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs b/VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs deleted file mode 100644 index 2f35e48..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs +++ /dev/null @@ -1,81 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: ClientRequestState.cs -* -* ClientRequestState.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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.SocialOauth -{ - internal sealed class ClientRequestState : ICacheable - { - private readonly ReadOnlyMemory _rawKey; - - /// - /// The raw nonce state bytes - /// - public ReadOnlyMemory State { get; private set; } - - public ClientRequestState(ReadOnlySpan keyChar, int nonceBytes) - { - //Get browser id - _rawKey = Convert.FromHexString(keyChar); - RecomputeState(nonceBytes); - } - - /// - /// Recomputes a nonce state and signature for the current - /// connection - /// - /// The size of the nonce (in bytes) to generate - public void RecomputeState(int nonceBytes) - { - //Get random nonce buffer - State = RandomHash.GetRandomBytes(nonceBytes); - } - /// - /// Computes the signature of the supplied data based on the original - /// client state for this connection - /// - /// - /// - public ERRNO ComputeSignatureForClient(ReadOnlySpan data, Span output) - { - return HMACSHA512.TryHashData(_rawKey.Span, data, output, out int count) ? count : ERRNO.E_FAIL; - } - - public DateTime Expires { get; set; } - bool IEquatable.Equals(ICacheable other) => ReferenceEquals(this, other); - void ICacheable.Evicted() - { - //Zero secrets on eviction - Memory.UnsafeZeroMemory(State); - Memory.UnsafeZeroMemory(_rawKey); - } - } -} \ No newline at end of file diff --git a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs deleted file mode 100644 index c7512b7..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs +++ /dev/null @@ -1,195 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: Auth0.cs -* -* Auth0.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Generic; - -using RestSharp; - -using VNLib.Net.Rest.Client; -using VNLib.Hashing; -using VNLib.Hashing.IdentityUtility; -using VNLib.Utils.Logging; -using VNLib.Plugins.Essentials.Accounts; -using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Loading.Users; - -namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints -{ - - [ConfigurationName("auth0")] - internal sealed class Auth0 : SocialOauthBase - { - - protected override OauthClientConfig Config { get; } - - - private readonly Task Auth0VerificationJwk; - - public Auth0(PluginBase plugin, IReadOnlyDictionary config) : base() - { - string keyUrl = config["key_url"].GetString() ?? throw new KeyNotFoundException("Missing Auth0 'key_url' from config"); - - Uri keyUri = new(keyUrl); - - //Get certificate on background thread - Auth0VerificationJwk = Task.Run(() => GetRsaCertificate(keyUri)); - - Config = new("auth0", config) - { - Passwords = plugin.GetPasswords(), - Users = plugin.GetUserManager(), - }; - - InitPathAndLog(Config.EndpointPath, plugin.Log); - - //Load secrets - _ = plugin.DeferTask(async () => - { - //Get id/secret - Task secretTask = plugin.TryGetSecretAsync("auth0_client_secret"); - Task clientIdTask = plugin.TryGetSecretAsync("auth0_client_id"); - - await Task.WhenAll(secretTask, clientIdTask); - - using SecretResult? secret = await secretTask; - using SecretResult? clientId = await clientIdTask; - - Config.ClientID = clientId?.Result.ToString() ?? throw new KeyNotFoundException("Missing Auth0 client id from config or vault"); - Config.ClientSecret = secret?.Result.ToString() ?? throw new KeyNotFoundException("Missing the Auth0 client secret from config or vault"); - - }, 100); - } - - - private async Task GetRsaCertificate(Uri certUri) - { - try - { - Log.Debug("Getting Auth0 signing keys"); - //Get key request - RestRequest keyRequest = new(certUri, Method.Get); - keyRequest.AddHeader("Accept", "application/json"); - - //rent client from pool - using ClientContract client = ClientPool.Lease(); - - RestResponse response = await client.Resource.ExecuteAsync(keyRequest); - - response.ThrowIfError(); - - return JsonDocument.Parse(response.RawBytes); - } - catch (Exception e) - { - Log.Error(e, "Failed to get Auth0 signing keys"); - throw; - } - } - - /* - * Account data may be recovered from the identity token - * and it happens after a call to GetLoginData so - * we do not need to re-verify the token - */ - protected override Task GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken) - { - using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken); - - //verify signature - - using JsonDocument userData = jwt.GetPayload(); - - if (!userData.RootElement.GetProperty("email_verified").GetBoolean()) - { - return Task.FromResult(null); - } - - string fullName = userData.RootElement.GetProperty("name").GetString() ?? " "; - - return Task.FromResult(new AccountData() - { - EmailAddress = userData.RootElement.GetProperty("email").GetString(), - First = fullName.Split(' ')[0], - Last = fullName.Split(' ')[1], - }); - } - - private static string GetUserIdFromPlatform(string userName) - { - /* - * Auth0 uses the format "platoform|{user_id}" for the user id so it should match the - * external platofrm as github and discord endoints also - */ - - return ManagedHash.ComputeHash(userName, HashAlg.SHA1, HashEncodingMode.Hexadecimal); - } - - - private static readonly Task EmptyLoginData = Task.FromResult(null); - - protected override Task GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation) - { - using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken); - - //Verify the token against the first signing key - if (!jwt.VerifyFromJwk(Auth0VerificationJwk.Result.RootElement.GetProperty("keys").EnumerateArray().First())) - { - return EmptyLoginData; - } - - using JsonDocument userData = jwt.GetPayload(); - - int iat = userData.RootElement.GetProperty("iat").GetInt32(); - int exp = userData.RootElement.GetProperty("exp").GetInt32(); - - string userId = userData.RootElement.GetProperty("sub").GetString() ?? throw new Exception("Missing sub in jwt"); - string audience = userData.RootElement.GetProperty("aud").GetString() ?? throw new Exception("Missing aud in jwt"); - string issuer = userData.RootElement.GetProperty("iss").GetString() ?? throw new Exception("Missing iss in jwt"); - - if(exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds()) - { - //Expired - return EmptyLoginData; - } - - //Verify audience matches client id - if (!Config.ClientID.Equals(audience, StringComparison.Ordinal)) - { - //Invalid audience - return EmptyLoginData; - } - - return Task.FromResult(new UserLoginData() - { - UserId = GetUserIdFromPlatform(userId) - }); - } - } -} diff --git a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs deleted file mode 100644 index d8b2394..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs +++ /dev/null @@ -1,158 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: DiscordOauth.cs -* -* DiscordOauth.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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.Threading; -using System.Text.Json; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -using RestSharp; - -using VNLib.Hashing; -using VNLib.Utils.Logging; -using VNLib.Net.Rest.Client; -using VNLib.Plugins.Essentials.Accounts; -using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Loading.Users; - - -namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints -{ - [ConfigurationName("discord")] - internal sealed class DiscordOauth : SocialOauthBase - { - protected override OauthClientConfig Config { get; } - - public DiscordOauth(PluginBase plugin, IReadOnlyDictionary config) : base() - { - Config = new("discord", config) - { - Passwords = plugin.GetPasswords(), - Users = plugin.GetUserManager(), - }; - - InitPathAndLog(Config.EndpointPath, plugin.Log); - - //Load secrets - _ = plugin.DeferTask(async () => - { - //Get id/secret - Task clientIdTask = plugin.TryGetSecretAsync("discord_client_id"); - Task secretTask = plugin.TryGetSecretAsync("discord_client_secret"); - - await Task.WhenAll(secretTask, clientIdTask); - - using SecretResult? secret = await secretTask; - using SecretResult? clientId = await clientIdTask; - - Config.ClientID = clientId?.Result.ToString() ?? throw new KeyNotFoundException("Missing Discord client id from config or vault"); - Config.ClientSecret = secret?.Result.ToString() ?? throw new KeyNotFoundException("Missing the Discord client secret from config or vault"); - - }, 100); - } - - - private static string GetUserIdFromPlatform(string userName) - { - return ManagedHash.ComputeHash($"discord|{userName}", HashAlg.SHA1, HashEncodingMode.Hexadecimal); - } - - - /* - * Matches the profile endpoint (@me) json object - */ - private sealed class UserProfile - { - [JsonPropertyName("username")] - public string? Username { get; set; } - [JsonPropertyName("id")] - public string? UserID { get; set; } - [JsonPropertyName("url")] - public string? ProfileUrl { get; set; } - [JsonPropertyName("verified")] - public bool Verified { get; set; } - [JsonPropertyName("email")] - public string? EmailAddress { get; set; } - } - - - protected override async Task GetAccountDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) - { - //Get the user's email address's - RestRequest request = new(Config.UserDataUrl); - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - //Get client from pool - using ClientContract client = ClientPool.Lease(); - //get user's profile data - RestResponse getProfileResponse = await client.Resource.ExecuteAsync(request, cancellationToken: cancellationToken); - //Check response - if (!getProfileResponse.IsSuccessful || getProfileResponse.Data == null) - { - Log.Debug("Discord user request responded with code {code}:{data}", getProfileResponse.StatusCode, getProfileResponse.Content); - return null; - } - UserProfile discordProfile = getProfileResponse.Data; - //Make sure the user's account is verified - if (!discordProfile.Verified) - { - return null; - } - return new() - { - EmailAddress = discordProfile.EmailAddress, - First = discordProfile.Username, - }; - } - - protected override async Task GetLoginDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) - { - //Get the user's email address's - RestRequest request = new(Config.UserDataUrl); - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - //Get client from pool - using ClientContract client = ClientPool.Lease(); - //get user's profile data - RestResponse getProfileResponse = await client.Resource.ExecuteAsync(request, cancellationToken: cancellationToken); - //Check response - if (!getProfileResponse.IsSuccessful || getProfileResponse.Data?.UserID == null) - { - Log.Debug("Discord user request responded with code {code}:{data}", getProfileResponse.StatusCode, getProfileResponse.Content); - return null; - } - - UserProfile discordProfile = getProfileResponse.Data; - - return new() - { - //Get unique user-id from the discord profile and sha1 hex hash to store in db - UserId = GetUserIdFromPlatform(discordProfile.UserID) - }; - } - } -} \ No newline at end of file diff --git a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs deleted file mode 100644 index 676f2bb..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs +++ /dev/null @@ -1,219 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: GitHubOauth.cs -* -* GitHubOauth.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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.Threading; -using System.Text.Json; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -using RestSharp; - -using VNLib.Hashing; -using VNLib.Utils.Logging; -using VNLib.Net.Rest.Client; -using VNLib.Plugins.Essentials.Accounts; -using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Loading.Users; - -namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints -{ - [ConfigurationName("github")] - internal sealed partial class GitHubOauth : SocialOauthBase - { - private const string GITHUB_V3_ACCEPT = "application/vnd.github.v3+json"; - - private readonly string UserEmailUrl; - - protected override OauthClientConfig Config { get; } - - public GitHubOauth(PluginBase plugin, IReadOnlyDictionary config) : base() - { - - UserEmailUrl = config["user_email_url"].GetString() ?? throw new KeyNotFoundException("Missing required key 'user_email_url' for github configuration"); - - Config = new("github", config) - { - Passwords = plugin.GetPasswords(), - Users = plugin.GetUserManager(), - }; - - InitPathAndLog(Config.EndpointPath, plugin.Log); - - //Load secrets - _ = plugin.DeferTask(async () => - { - //Get id/secret - Task clientIdTask = plugin.TryGetSecretAsync("github_client_id"); - Task secretTask = plugin.TryGetSecretAsync("github_client_secret"); - - await Task.WhenAll(secretTask, clientIdTask); - - using SecretResult? secret = await secretTask; - using SecretResult? clientId = await clientIdTask; - - Config.ClientID = clientId?.Result.ToString() ?? throw new KeyNotFoundException("Missing Github client id from config or vault"); - Config.ClientSecret = secret?.Result.ToString() ?? throw new KeyNotFoundException("Missing the Github client secret from config or vault"); - - }, 100); - } - - protected override void StaticClientPoolInitializer(RestClient client) - { - client.UseSerializer(); - //add accept types of normal json and github json - client.AcceptedContentTypes = new string[2] { "application/json", GITHUB_V3_ACCEPT }; - } - - /* - * Matches the json result from the - */ - private sealed class GithubProfile - { - [JsonPropertyName("login")] - public string? Username { get; set; } - [JsonPropertyName("id")] - public int ID { get; set; } - [JsonPropertyName("node_id")] - public string? NodeID { get; set; } - [JsonPropertyName("avatar_url")] - public string? AvatarUrl { get; set; } - [JsonPropertyName("url")] - public string? ProfileUrl { get; set; } - [JsonPropertyName("type")] - public string? Type { get; set; } - [JsonPropertyName("name")] - public string? FullName { get; set; } - [JsonPropertyName("company")] - public string? Company { get; set; } - } - /* - * Matches the required data from the github email endpoint - */ - private sealed class EmailContainer - { - [JsonPropertyName("email")] - public string? Email { get; set; } - [JsonPropertyName("primary")] - public bool Primary { get; set; } - [JsonPropertyName("verified")] - public bool Verified { get; set; } - } - - private static string GetUserIdFromPlatform(int userId) - { - return ManagedHash.ComputeHash($"github|{userId}", HashAlg.SHA1, HashEncodingMode.Hexadecimal); - } - - protected override async Task GetLoginDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) - { - //Get the user's email address's - RestRequest request = new(Config.UserDataUrl, Method.Get); - - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - - //Get new client from pool - using ClientContract client = ClientPool.Lease(); - - //Exec the get for the profile - RestResponse profResponse = await client.Resource.ExecuteAsync(request, cancellationToken); - - if (!profResponse.IsSuccessful || profResponse.Data == null || profResponse.Data.ID < 100) - { - Log.Debug("Github login data attempt responded with status code {code}", profResponse.StatusCode); - return null; - } - - //Return login data - return new() - { - //User-id is just the SHA 1 - UserId = GetUserIdFromPlatform(profResponse.Data.ID) - }; - } - - protected override async Task GetAccountDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken = default) - { - AccountData? accountData = null; - //Get the user's email address's - RestRequest request = new(UserEmailUrl, Method.Get); - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - - using ClientContract client = ClientPool.Lease(); - - //get user's emails - RestResponse getEmailResponse = await client.Resource.ExecuteAsync(request, cancellationToken: cancellationToken); - //Check status - if (getEmailResponse.IsSuccessful && getEmailResponse.Data != null) - { - //Filter emails addresses - foreach (EmailContainer email in getEmailResponse.Data) - { - //Capture the first primary email address and make sure its verified - if (email.Primary && email.Verified) - { - accountData = new() - { - //store email on current profile - EmailAddress = email.Email - }; - goto Continue; - } - } - //No primary email found - return null; - } - else - { - Log.Debug("Github account data request failed but GH responded with status code {code}", getEmailResponse.StatusCode); - return null; - } - Continue: - //We need to get the user's profile in order to create a new account - request = new(Config.UserDataUrl, Method.Get); - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - //Exec the get for the profile - RestResponse profResponse = await client.Resource.ExecuteAsync(request, cancellationToken); - if (!profResponse.IsSuccessful || profResponse.Data == null) - { - Log.Debug("Github account data request failed but GH responded with status code {code}", profResponse.StatusCode); - return null; - } - - //Get the user's name from gh profile - string[] names = profResponse.Data.FullName!.Split(" ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - //setup the user's profile data - accountData.First = names.Length > 0 ? names[0] : string.Empty; - accountData.Last = names.Length > 1 ? names[1] : string.Empty; - return accountData; - } - - } -} \ No newline at end of file diff --git a/VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs b/VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs deleted file mode 100644 index 888cc02..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs +++ /dev/null @@ -1,57 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: IOAuthAccessState.cs -* -* IOAuthAccessState.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU Affero General Public License for more details. -* -* You should have received a copy of the GNU Affero General Public License -* along with this program. If not, see https://www.gnu.org/licenses/. -*/ - -#nullable enable - -namespace VNLib.Plugins.Essentials.SocialOauth -{ - /// - /// An object that represents an OAuth2 access token in its - /// standard form. - /// - public interface IOAuthAccessState - { - /// - /// The OAuth2 access token - /// - public string? Token { get; set; } - /// - /// Token grant scope - /// - string? Scope { get; set; } - /// - /// The OAuth2 token type, usually 'Bearer' - /// - string? Type { get; set; } - /// - /// Optional refresh token - /// - string? RefreshToken { get; set; } - - /// - /// Optional ID OIDC token - /// - string? IdToken { get; set; } - } -} \ No newline at end of file diff --git a/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs b/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs deleted file mode 100644 index 9caf705..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs +++ /dev/null @@ -1,126 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: OauthClientConfig.cs -* -* OauthClientConfig.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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.Collections.Generic; - -using VNLib.Utils.Extensions; -using VNLib.Plugins.Essentials.Users; -using VNLib.Plugins.Essentials.Accounts; - - -namespace VNLib.Plugins.Essentials.SocialOauth -{ - - public sealed class OauthClientConfig - { - - public OauthClientConfig(string configName, IReadOnlyDictionary config) - { - EndpointPath = config["path"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'path' in config {configName}"); - - //Set discord account origin - AccountOrigin = config["account_origin"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'account_origin' in config {configName}"); - - //Get the auth and token urls - string authUrl = config["authorization_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'authorization_url' in config {configName}"); - string tokenUrl = config["token_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'token_url' in config {configName}"); - string userUrl = config["user_data_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'user_data_url' in config {configName}"); - //Create the uris - AccessCodeUrl = new(authUrl); - AccessTokenUrl = new(tokenUrl); - UserDataUrl = new(userUrl); - - AllowForLocalAccounts = config["allow_for_local"].GetBoolean(); - AllowRegistration = config["allow_registration"].GetBoolean(); - LoginNonceLifetime = config["valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); - NonceByteSize = config["nonce_size"].GetUInt32(); - RandomPasswordSize = config["password_size"].GetInt32(); - } - - - public string ClientID { get; set; } = string.Empty; - - public string ClientSecret { get; set; } = string.Empty; - - - /// - /// The user-account origin value. Specifies that the user account - /// was created outside of the local account system - /// - public string AccountOrigin { get; } - - /// - /// The URL to redirect the user to the OAuth2 service - /// to begin the authentication process - /// - public Uri AccessCodeUrl { get; } - - /// - /// The remote endoint to exchange codes for access tokens - /// - public Uri AccessTokenUrl { get; } - - /// - /// The endpoint to get user-data object from - /// - public Uri UserDataUrl { get; } - - public TimeSpan LoginNonceLifetime { get; } - /// - /// The user store to create/get users from - /// - public IUserManager Users { get; init; } - - public PasswordHashing Passwords { get; init; } - - /// - /// The endpoint route/path - /// - public string EndpointPath { get; } - - /// - /// The size (in bytes) of the random generated nonce - /// - public uint NonceByteSize { get; } - - /// - /// A value that specifies if locally created accounts are allowed - /// to be logged in from an OAuth2 source - /// - public bool AllowForLocalAccounts { get; } - - /// - /// A value that indicates if accounts that do not exist will be created - /// and logged in immediatly, on successfull OAuth2 flow - /// - public bool AllowRegistration { get; } - - /// - /// The size (in bytes) of the random password generated for new users - /// - public int RandomPasswordSize { get; } - } -} diff --git a/VNLib.Plugins.Essentials.SocialOauth/README.md b/VNLib.Plugins.Essentials.SocialOauth/README.md deleted file mode 100644 index 7a54c20..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# VNLib.Plugins.Essentials.SocialOauth - -A basic external OAuth2 authentication plugin. - -## Plugin Mode - -This library exports an IPlugin type that may be loaded directly by a host application, or -imported to provide base classes for creating OAuth2 authentication endpoints. - -By default, exports 2 endpoints for Github and Discord authentication. Configuration -variables for either endpoint may be omitted or included to export endpoints. - -## Library Mode - -Exports SocialOAuthBase to provide a base class for creating OAuth2 authentication -endpoints, that is compatible with the VNLib web client library authentication flow - - -## Authentication Flow - -The authentication flow works similar to the local account mechanism with an extra step that helps -guard against replay, and MITM attacks. When an request claim is made (request to login) from client -side code (via put request), a browser id is request (for login flow) along with the clients encryption -public key (same key as Essentials.Accounts requires). The public key is used to encrypted a derived -redirect url, which includes a "secret" state token (OAuth2 standard state) that only the private-key -holder should be able to recover. When decrypted, should be used to redirect the client's browser to -the remote authentication server. Assuming the request is granted, the browser is redirected to the -originating endpoint, and the nonce is used to recover the initial claim and the flow continues. The -request should also include the required OAuth2 'code' parameter used to exchange for an access token. -If the access token is granted, a nonce is generated, passed to the browser via a redirect query parameter -which the browser code will use in a POST request to the endpoint to continue the flow. The nonce is -used to recover the access token and original claim data (public key, browser id, etc), which is used -to recover a user account, or optionally create a new account. Once complete, the user account is used -to upgrade the session and grant authorization to the client. The public key (and browser id) is used -from the initial claim to authorize the session, which should guard against MITM, replay, and forgery -attacks. However this only works if we assume the clients private key has not been stolen, which is a -much larger issue and should be addressed separately. - -## Diagram - -PUT -> { public_key, browser_id } -> server -> { result: "base64 encrypted redirect url"} -> - OAuth2Server -> redirect -> "?code=some_code&state=decrypted_state_token" - -GET -> "?code=some_code&state=decrypted_state_token" -> server -> "?result=authorized&nonce=some_nonce" -POST -> { nonce:"some_nonce" } -> server -> [authorization complete message] \ No newline at end of file diff --git a/VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs b/VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs deleted file mode 100644 index d0f7a84..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs +++ /dev/null @@ -1,82 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: SocialEntryPoint.cs -* -* SocialEntryPoint.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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.Logging; -using VNLib.Plugins.Essentials.SocialOauth.Endpoints; -using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Loading.Routing; - -namespace VNLib.Plugins.Essentials.SocialOauth -{ - public sealed class SocialEntryPoint : PluginBase - { - - public override string PluginName => "Essentials.SocialOauth"; - - protected override void OnLoad() - { - try - { - //Get the discord oauth config from the config file - if (this.HasConfigForType()) - { - //Add the discord login endpoint - this.Route(); - Log.Information("Discord social OAuth authentication loaded"); - } - if (this.HasConfigForType()) - { - //Add the github login endpoint - this.Route(); - Log.Information("Github social OAuth authentication loaded"); - } - - if (this.HasConfigForType()) - { - //Add the auth0 login endpoint - this.Route(); - Log.Information("Auth0 social OAuth authentication loaded"); - } - } - catch(KeyNotFoundException kne) - { - Log.Error("Missing required configuration variables, {reason}", kne.Message); - } - } - - - protected override void OnUnLoad() - { - Log.Information("Plugin unloaded"); - } - - protected override void ProcessHostCommand(string cmd) - { - throw new NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs b/VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs deleted file mode 100644 index 6815bf3..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs +++ /dev/null @@ -1,617 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: SocialOauthBase.cs -* -* SocialOauthBase.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Security.Cryptography; -using System.Text.Json.Serialization; -using System.Runtime.InteropServices; - -using FluentValidation; - -using RestSharp; -using VNLib.Net.Http; -using VNLib.Net.Rest.Client; -using VNLib.Hashing; -using VNLib.Utils; -using VNLib.Utils.Memory; -using VNLib.Utils.Logging; -using VNLib.Utils.Extensions; -using VNLib.Utils.Memory.Caching; -using VNLib.Plugins.Essentials.Users; -using VNLib.Plugins.Essentials.Accounts; -using VNLib.Plugins.Essentials.Endpoints; -using VNLib.Plugins.Essentials.Extensions; -using VNLib.Plugins.Extensions.Validation; -using VNLib.Plugins.Essentials.SocialOauth.Validators; - -namespace VNLib.Plugins.Essentials.SocialOauth -{ - - /// - /// Provides a base class for derriving commong OAuth2 implicit authentication - /// - public abstract class SocialOauthBase : UnprotectedWebEndpoint - { - const string AUTH_ERROR_MESSAGE = "You have no pending authentication requests."; - - const string AUTH_GRANT_SESSION_NAME = "auth"; - - /// - /// The client configuration struct passed during base class construction - /// - protected abstract OauthClientConfig Config { get; } - - /// - protected override ProtectionSettings EndpointProtectionSettings { get; } = new() - { - /* - * Disable cross site checking because the OAuth2 flow requires - * cross site when redirecting the client back - */ - DisableCrossSiteDenied = true - }; - - /// - /// The resst client connection pool - /// - protected RestClientPool ClientPool { get; } - - private readonly Dictionary ClaimStore; - private readonly Dictionary AuthorizationStore; - private readonly IValidator ClaimValidator; - private readonly IValidator NonceValidator; - private readonly IValidator AccountDataValidator; - - protected SocialOauthBase() - { - ClaimStore = new(StringComparer.OrdinalIgnoreCase); - AuthorizationStore = new(StringComparer.OrdinalIgnoreCase); - ClaimValidator = GetClaimValidator(); - NonceValidator = GetNonceValidator(); - AccountDataValidator = new AccountDataValidator(); - - 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 - ClientPool = new(10, poolOptions, StaticClientPoolInitializer); - } - - private static IValidator GetClaimValidator() - { - InlineValidator val = new(); - val.RuleFor(static s => s.ClientId) - .Length(10, 100) - .WithMessage("Request is not valid"); - - val.RuleFor(static s => s.PublicKey) - .Length(50, 1024) - .WithMessage("Request is not valid"); - - return val; - } - private static IValidator GetNonceValidator() - { - InlineValidator val = new(); - val.RuleFor(static s => s) - .Length(10, 200) - //Nonces are base32, so only alpha num - .AlphaNumeric(); - return val; - } - - 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 - if(entity.LoginCookieMatches() || entity.TokenMatches()) - { - return false; - } - return true; - } - - /// - /// Invoked by the constructor during rest client initlialization - /// - /// The new client to be configured - protected virtual void StaticClientPoolInitializer(RestClient client) - { - client.AddDefaultHeader("accept", HttpHelpers.GetContentTypeString(ContentType.Json)); - client.UseSerializer(); - } - - protected virtual void OnBeforeGetToken(HttpEntity entity, string code, RestRequest state) { } - - /// - /// When derrived in a child class, exchanges an OAuth2 code grant type - /// for an OAuth2 access token to make api requests - /// - /// - /// The raw code from the remote OAuth2 granting server - /// A token to cancel the operation - /// - /// A task the resolves the that includes all relavent - /// authorization data. Result may be null if authorzation is invalid or not granted - /// - /// - protected async Task ExchangeCodeForTokenAsync(HttpEntity ev, string code, CancellationToken cancellationToken) - { - //valid response, time to get the actual authorization from gh for client - RestRequest request = new(Config.AccessTokenUrl, Method.Post); - - //Add required params url-encoded - request.AddParameter("client_id", Config.ClientID, ParameterType.GetOrPost); - request.AddParameter("client_secret", Config.ClientSecret, ParameterType.GetOrPost); - request.AddParameter("grant_type", "authorization_code", ParameterType.GetOrPost); - request.AddParameter("code", code, ParameterType.GetOrPost); - request.AddParameter("redirect_uri", $"{ev.Server.RequestUri.Scheme}://{ev.Server.RequestUri.Authority}{Path}", ParameterType.GetOrPost); - - //Allow reconfiguration - OnBeforeGetToken(ev, code, request); - - //Get client from pool - using ClientContract client = ClientPool.Lease(); - //Execute request and attempt to recover the authorization response - RestResponse response = await client.Resource.ExecuteAsync(request, cancellationToken: cancellationToken); - //Make sure successfull, if so return the access token to store - return response.IsSuccessful && response.Data != null ? response.Data : null; - } - - /// - /// Gets an object that represents the user's account data from the OAuth provider when - /// creating a new user for the current platform - /// - /// The access state from the code/token exchange - /// A token to cancel the operation - /// The user's account data, null if not account exsits on the remote site, and process cannot continue - /// - protected abstract Task GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken); - /// - /// Gets an object that represents the required information for logging-in a user (namley unique user-id) - /// - /// The authorization information granted from the OAuth2 authorization server - /// A token to cancel the operation - /// - protected abstract Task GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation); - - class LoginClaim : ICacheable, INonce - { - [JsonPropertyName("public_key")] - public string? PublicKey { get; set; } - [JsonPropertyName("browser_id")] - public string? ClientId { get; set; } - - /// - /// The raw OAuth flow state parameter the client must decrypt before - /// navigating to remote authentication source - /// - [JsonIgnore] - public ReadOnlyMemory RawNonce { get; private set; } - [JsonIgnore] - DateTime ICacheable.Expires { get; set; } - bool IEquatable.Equals(ICacheable? other) => Equals(other); - void ICacheable.Evicted() - { - //Erase nonce - Memory.UnsafeZeroMemory(RawNonce); - } - - public override bool Equals(object? obj) - { - return obj is LoginClaim otherClaim && this.PublicKey!.Equals(otherClaim.PublicKey, StringComparison.Ordinal); - } - public override int GetHashCode() => PublicKey!.GetHashCode(); - - void INonce.ComputeNonce(Span buffer) - { - RandomHash.GetRandomBytes(buffer); - //Store copy - RawNonce = buffer.ToArray(); - } - - bool INonce.VerifyNonce(ReadOnlySpan nonceBytes) - { - return CryptographicOperations.FixedTimeEquals(RawNonce.Span, nonceBytes); - } - } - - /* - * Get method is invoked when the remote OAuth2 control has been passed back - * to this server. If successfull should include a code that grants authorization - * and include a state variable that the client decrypted from an initial claim - * to prove its identity - */ - - protected override async ValueTask 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()) - { - //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 (ClaimStore.TryGetOrEvictRecord(state, out LoginClaim? claim) < 1) - { - entity.Redirect(RedirectType.Temporary, $"{Path}?result=expired"); - return VfReturnType.VirtualSkip; - } - //Lock on the claim to prevent replay - lock (claim) - { - bool isValid = claim.VerifyNonce(state); - //Evict the record inside the lock, also wipes nonce contents - ClaimStore.EvictRecord(state); - - //Compare binary values of nonce incase of dicionary collision - if (!isValid) - { - 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) - { - entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); - return VfReturnType.VirtualSkip; - } - //Store claim info - token.PublicKey = claim.PublicKey; - token.ClientId = claim.ClientId; - //Generate the new nonce - string nonce = token.ComputeNonce((int)Config.NonceByteSize); - //Collect expired records - AuthorizationStore.CollectRecords(); - //Register the access token - AuthorizationStore.StoreRecord(nonce, token, Config.LoginNonceLifetime); - //Prepare redirect - entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={nonce}"); - return VfReturnType.VirtualSkip; - } - //Check to see if there was an error code set - if (entity.QueryArgs.TryGetNonEmptyValue("error", out string? errorCode)) - { - 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 - */ - - protected override async ValueTask 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")) - { - entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); - return VfReturnType.VirtualSkip; - } - //Recover the nonce - string? base32Nonce = request.RootElement.GetPropString("nonce"); - if(webm.Assert(base32Nonce != null, message: "Nonce parameter is required")) - { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; - } - //Validate nonce - if (!NonceValidator.Validate(base32Nonce, webm)) - { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; - } - //Recover the access token - if (AuthorizationStore.TryGetOrEvictRecord(base32Nonce!, out OAuthAccessState? token) < 1) - { - webm.Result = AUTH_ERROR_MESSAGE; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - bool valid; - //Valid token, now verify the nonce within the locked context - lock (token) - { - valid = token.VerifyNonce(base32Nonce); - //Evict (wipes nonce) - AuthorizationStore.EvictRecord(base32Nonce!); - } - if (webm.Assert(valid, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - //get the user's login information (ie userid) - UserLoginData? userLogin = await GetLoginDataAsync(token, entity.EventCancellation); - - if(webm.Assert(userLogin?.UserId != null, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - //Fetch the user from the database - IUser? user = await Config.Users.GetUserFromIDAsync(userLogin.UserId, entity.EventCancellation); - - if(user == null) - { - //Get the clients personal info to being login process - AccountData? userAccount = await GetAccountDataAsync(token, entity.EventCancellation); - - if (webm.Assert(userAccount != null, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - //Validate the account data - if (webm.Assert(AccountDataValidator.Validate(userAccount).IsValid, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - //make sure registration is enabled - if (webm.Assert(Config.AllowRegistration, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - //Create new user, create random passwords - byte[] randomPass = RandomHash.GetRandomBytes(Config.RandomPasswordSize); - //Generate a new random passowrd incase the user wants to use a local account to log in sometime in the future - PrivateString passhash = Config.Passwords.Hash(randomPass); - //overwite the password bytes - Memory.InitializeBlock(randomPass.AsSpan()); - try - { - //Create the user with the specified email address, minimum privilage level, and an empty password - user = await Config.Users.CreateUserAsync(userLogin.UserId!, userAccount.EmailAddress, AccountManager.MINIMUM_LEVEL, passhash, entity.EventCancellation); - //Set active status - user.Status = UserStatus.Active; - //Store the new profile - user.SetProfile(userAccount); - //Set the account creation origin - 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"; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - finally - { - passhash.Dispose(); - } - } - else - { - //Check for local only - if (webm.Assert(!user.LocalOnly, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - //Make sure local accounts are allowed - if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - //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)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - } - //Finalze login - try - { - //Generate authoization - webm.Token = entity.GenerateAuthorization(token.PublicKey!, token.ClientId!, user); - //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 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; - entity.InvalidateLogin(); - Log.Error(uue); - } - finally - { - user.Dispose(); - } - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - /* - * 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 - */ - - protected override async ValueTask PutAsync(HttpEntity entity) - { - ValErrWebMessage webm = new(); - - //Get the login message - LoginClaim? claim = await entity.GetJsonFromFileAsync(); - - 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; - } - - //Cleanup old records - ClaimStore.CollectRecords(); - //Set nonce - string base32Nonce = claim.ComputeNonce((int)Config.NonceByteSize); - //build the redirect url - webm.Result = BuildUrl(base32Nonce, claim.PublicKey!, entity.IsSecure ? "https" : "http", entity.Server.RequestUri.Authority, entity.Server.Encoding); - //Store the claim - ClaimStore.StoreRecord(base32Nonce, claim, Config.LoginNonceLifetime); - webm.Success = true; - //Response - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - /* - * 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 - */ - - private string BuildUrl(string base32Nonce, string pubKey, ReadOnlySpan scheme, ReadOnlySpan redirectAuthority, Encoding enc) - { - //Char buffer for base32 and url building - using UnsafeMemoryHandle buffer = Memory.UnsafeAlloc(8192, true); - //get bin buffer slice - Span binBuffer = buffer.Span[1024..]; - - ReadOnlySpan url; - { - //Get char buffer slice and cast to char - Span charBuf = MemoryMarshal.Cast(buffer.Span[..1024]); - //buffer writer for easier syntax - ForwardOnlyWriter writer = new(charBuf); - //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(redirectAuthority); - writer.Append(Path); - //url encode the redirect path and save it for later - string redirectFiltered = Uri.EscapeDataString(writer.ToString()); - //reset the writer again to begin building the path - writer.Reset(); - //Append the config redirect path - writer.Append(Config.AccessCodeUrl.OriginalString); - //begin query arguments - writer.Append("&client_id="); - writer.Append(Config.ClientID); - //add the redirect url - writer.Append("&redirect_uri="); - writer.Append(redirectFiltered); - //Append the state parameter - writer.Append("&state="); - writer.Append(base32Nonce); - url = writer.AsSpan(); - } - //Separate buffers - Span encryptionBuffer = binBuffer[1024..]; - Span encodingBuffer = binBuffer[..1024]; - //Encode the url to binary - int byteCount = enc.GetBytes(url, encodingBuffer); - //Encrypt the binary - ERRNO count = AccountManager.TryEncryptClientData(pubKey, encodingBuffer[..byteCount], in encryptionBuffer); - //base64 encode the encrypted - return Convert.ToBase64String(encryptionBuffer[0..(int)count]); - } - } -} diff --git a/VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs b/VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs deleted file mode 100644 index cb2406c..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs +++ /dev/null @@ -1,36 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: UserLoginData.cs -* -* UserLoginData.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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; - -#nullable enable - -namespace VNLib.Plugins.Essentials.SocialOauth -{ - public class UserLoginData - { - [JsonPropertyName("user_id")] - public string? UserId { get; set; } - } -} diff --git a/VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj b/VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj deleted file mode 100644 index 25abcb8..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj +++ /dev/null @@ -1,54 +0,0 @@ - - - - net6.0 - Vaughn Nugent - SocialOauth - 1.0.1.5 - Copyright © 2022 Vaughn Nugent - https://www.vaughnnugent.com/resources - SocialOauth - True - \\vaughnnugent.com\Internal\Folder Redirection\vman\Documents\Programming\Software\StrongNameingKey.snk - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - true - enable - False - latest-all - - - - - - - - - - - - Always - - - - - - - - diff --git a/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs b/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs deleted file mode 100644 index 7ebb37e..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs +++ /dev/null @@ -1,74 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: AccountDataValidator.cs -* -* AccountDataValidator.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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; - -#nullable enable - -namespace VNLib.Plugins.Essentials.SocialOauth.Validators -{ - internal class AccountDataValidator : AbstractValidator - { - public AccountDataValidator() : base() - { - RuleFor(t => t.EmailAddress) - .NotEmpty() - .WithMessage("Your account does not have an email address assigned to it"); - - RuleFor(t => t.EmailAddress) - .EmailAddress() - .WithMessage("Your account does not have a valid email address assigned to it"); - - //Validate city - RuleFor(t => t.City).MaximumLength(50); - RuleFor(t => t.City).AlphaOnly(); - - RuleFor(t => t.Company).MaximumLength(50); - RuleFor(t => t.Company).SpecialCharacters(); - - RuleFor(t => t.First).MaximumLength(35); - RuleFor(t => t.First).AlphaOnly(); - - RuleFor(t => t.Last).MaximumLength(35); - RuleFor(t => t.Last).AlphaOnly(); - - RuleFor(t => t.PhoneNumber) - .EmptyPhoneNumber() - .OverridePropertyName("Phone"); - - //State must be 2 characters for us states if set - RuleFor(t => t.State).Length(t => t.State?.Length != 0 ? 2 : 0); - - RuleFor(t => t.Street).MaximumLength(50); - RuleFor(t => t.Street).AlphaNumericOnly(); - - RuleFor(t => t.Zip).NumericOnly(); - //Allow empty zip codes, but if one is defined, is must be less than 7 characters - RuleFor(t => t.Zip).Length(ad => ad.Zip?.Length != 0 ? 7 : 0); - } - } -} diff --git a/VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs b/VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs deleted file mode 100644 index 86893c5..0000000 --- a/VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs +++ /dev/null @@ -1,60 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.SocialOauth -* File: LoginMessageValidation.cs -* -* LoginMessageValidation.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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.SocialOauth.Validators -{ - internal class LoginMessageValidation : AbstractValidator - { - /* - * 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"); - - RuleFor(t => t.ClientPublicKey) - .NotEmpty() - .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"); - RuleFor(t => t.LocalLanguage).AlphaNumericOnly(); - } - } -} -- cgit