diff options
14 files changed, 1877 insertions, 0 deletions
diff --git a/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs b/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs new file mode 100644 index 0000000..4d6f38d --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs @@ -0,0 +1,87 @@ +/* +* 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; + +#nullable enable + +namespace VNLib.Plugins.Essentials.SocialOauth +{ + public sealed class OAuthAccessState : IOAuthAccessState, ICacheable, INonce + { + ///<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; } + + //Ignore the public key and client ids + [JsonIgnore] + internal string? PublicKey { get; set; } + [JsonIgnore] + internal string? ClientId { get; set; } + + /// <summary> + /// A random nonce generated when the access state is created and + /// deleted when then access token is evicted. + /// </summary> + [JsonIgnore] + internal ReadOnlyMemory<byte> Nonce { get; private set; } + + DateTime ICacheable.Expires { get; set; } + bool IEquatable<ICacheable>.Equals(ICacheable? other) => GetHashCode() == other?.GetHashCode(); + public override int GetHashCode() => Token!.GetHashCode(); + void ICacheable.Evicted() + { + Memory.UnsafeZeroMemory(Nonce); + } + + void INonce.ComputeNonce(Span<byte> buffer) + { + //Compute nonce + RandomHash.GetRandomBytes(buffer); + //Copy and store + Nonce = buffer.ToArray(); + } + + bool INonce.VerifyNonce(ReadOnlySpan<byte> 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 new file mode 100644 index 0000000..2f35e48 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs @@ -0,0 +1,81 @@ +/* +* 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<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 + 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 new file mode 100644 index 0000000..8518ea0 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs @@ -0,0 +1,190 @@ +/* +* 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; + +#nullable enable + +namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints +{ + + [ConfigurationName("auth0")] + internal class Auth0 : SocialOauthBase + { + protected override OauthClientConfig Config { get; } + + + private readonly Task<JsonDocument> RsaCertificate; + + public Auth0(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) + { + //Get id/secret + Task<string?> secret = plugin.TryGetSecretAsync("auth0_client_secret"); + Task<string?> clientId = plugin.TryGetSecretAsync("auth0_client_id"); + + //Wait sync + Task.WaitAll(secret, clientId); + + Config = new("auth0", config) + { + //get gh client secret and id + ClientID = clientId.Result ?? throw new KeyNotFoundException("Missing Auth0 client id from config or vault"), + ClientSecret = secret.Result ?? throw new KeyNotFoundException("Missing Auth0 client secret from config or vault"), + + Passwords = plugin.GetPasswords(), + Users = plugin.GetUserManager(), + }; + + string keyUrl = config["key_url"].GetString() ?? throw new KeyNotFoundException("Missing Auth0 'key_url' from config"); + + Uri keyUri = new(keyUrl); + + //Get certificate on background thread + RsaCertificate = Task.Run(() => GetRsaCertificate(keyUri)); + + InitPathAndLog(Config.EndpointPath, plugin.Log); + } + + + private async Task<JsonDocument> 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<AccountData?> 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<AccountData?>(null); + } + + string fullName = userData.RootElement.GetProperty("name").GetString() ?? " "; + + return Task.FromResult<AccountData?>(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<UserLoginData?> EmptyLoginData = Task.FromResult<UserLoginData?>(null); + + protected override Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation) + { + using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken); + + //Verify the token against the first signing key + if (!jwt.VerifyFromJwk(RsaCertificate.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<UserLoginData?>(new UserLoginData() + { + UserId = GetUserIdFromPlatform(userId) + }); + } + } +} diff --git a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs new file mode 100644 index 0000000..6ee7683 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs @@ -0,0 +1,152 @@ +/* +* 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; + +#nullable enable + +namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints +{ + [ConfigurationName("discord")] + internal sealed class DiscordOauth : SocialOauthBase + { + protected override OauthClientConfig Config { get; } + + public DiscordOauth(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) : base() + { + //Get id/secret + Task<string?> secret = plugin.TryGetSecretAsync("discord_client_secret"); + Task<string?> clientId = plugin.TryGetSecretAsync("discord_client_id"); + + //Wait sync + Task.WaitAll(secret, clientId); + + Config = new("discord", config) + { + //get gh client secret and id + ClientID = clientId.Result ?? throw new KeyNotFoundException("Missing Discord client id from config or vault"), + ClientSecret = secret.Result ?? throw new KeyNotFoundException("Missing the Discord client secret from config or vault"), + + Passwords = plugin.GetPasswords(), + Users = plugin.GetUserManager(), + }; + + InitPathAndLog(Config.EndpointPath, plugin.Log); + } + + 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<AccountData?> 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<UserProfile> getProfileResponse = await client.Resource.ExecuteAsync<UserProfile>(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<UserLoginData?> 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<UserProfile> getProfileResponse = await client.Resource.ExecuteAsync<UserProfile>(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 new file mode 100644 index 0000000..78b4b49 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs @@ -0,0 +1,214 @@ +/* +* 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; + +#nullable enable + +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<string, JsonElement> config) : base() + { + //Get id/secret + Task<string?> secret = plugin.TryGetSecretAsync("github_client_secret"); + Task<string?> clientId = plugin.TryGetSecretAsync("github_client_id"); + + //Wait sync + Task.WaitAll(secret, clientId); + + Config = new(configName: "github", config) + { + //get gh client secret and id + ClientID = clientId.Result ?? throw new KeyNotFoundException("Missing Github client id from config or vault"), + ClientSecret = secret.Result ?? throw new KeyNotFoundException("Missing Github client secret from config or vault"), + + Passwords = plugin.GetPasswords(), + Users = plugin.GetUserManager(), + }; + + + UserEmailUrl = config["user_email_url"].GetString() ?? throw new KeyNotFoundException("Missing required key 'user_email_url' for github configuration"); + + InitPathAndLog(Config.EndpointPath, plugin.Log); + } + + protected override void StaticClientPoolInitializer(RestClient client) + { + client.UseSerializer<RestSharp.Serializers.Json.SystemTextJsonSerializer>(); + //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<UserLoginData?> 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<GithubProfile> profResponse = await client.Resource.ExecuteAsync<GithubProfile>(request, cancellationToken); + + if (!profResponse.IsSuccessful || profResponse.Data == null || profResponse.Data.ID < 100) + { + Log.Debug("Client attempted a github login but GH 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<AccountData?> 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<EmailContainer[]> getEmailResponse = await client.Resource.ExecuteAsync<EmailContainer[]>(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 + accountData.EmailAddress = email.Email; + goto Continue; + } + } + //No primary email found + return null; + } + else + { + Log.Debug("Client attempted a github login 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<GithubProfile> profResponse = await client.Resource.ExecuteAsync<GithubProfile>(request, cancellationToken); + if (!profResponse.IsSuccessful || profResponse.Data == null) + { + Log.Debug("Client attempted a github login 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 new file mode 100644 index 0000000..888cc02 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs @@ -0,0 +1,57 @@ +/* +* 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 +{ + /// <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/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs b/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs new file mode 100644 index 0000000..6e30802 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs @@ -0,0 +1,130 @@ +/* +* 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; +using VNLib.Net.Rest.Client; + +#nullable enable + +namespace VNLib.Plugins.Essentials.SocialOauth +{ + + public sealed class OauthClientConfig + { + + public OauthClientConfig(string configName, IReadOnlyDictionary<string, JsonElement> 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 RestClientPool ClientPool { get; } + + public string ClientID { get; init; } + + public string ClientSecret { get; init; } + + + /// <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; } + + public TimeSpan LoginNonceLifetime { get; } + /// <summary> + /// The user store to create/get users from + /// </summary> + public IUserManager Users { get; init; } + + public PasswordHashing Passwords { get; init; } + + /// <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; } + } +} diff --git a/VNLib.Plugins.Essentials.SocialOauth/README.md b/VNLib.Plugins.Essentials.SocialOauth/README.md new file mode 100644 index 0000000..7a54c20 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/README.md @@ -0,0 +1,45 @@ +# 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 new file mode 100644 index 0000000..d0f7a84 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs @@ -0,0 +1,82 @@ +/* +* 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<DiscordOauth>()) + { + //Add the discord login endpoint + this.Route<DiscordOauth>(); + Log.Information("Discord social OAuth authentication loaded"); + } + if (this.HasConfigForType<GitHubOauth>()) + { + //Add the github login endpoint + this.Route<GitHubOauth>(); + Log.Information("Github social OAuth authentication loaded"); + } + + if (this.HasConfigForType<Auth0>()) + { + //Add the auth0 login endpoint + this.Route<Auth0>(); + 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 new file mode 100644 index 0000000..72d0d1b --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs @@ -0,0 +1,619 @@ +/* +* 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; + +#nullable enable + +namespace VNLib.Plugins.Essentials.SocialOauth +{ + + /// <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"; + + /// <summary> + /// The client configuration struct passed during base class construction + /// </summary> + protected abstract OauthClientConfig Config { get; } + + ///<inheritdoc/> + protected override ProtectionSettings EndpointProtectionSettings { get; } = new() + { + /* + * Disable cross site checking because the OAuth2 flow requires + * cross site when redirecting the client back + */ + CrossSiteDenied = false + }; + + /// <summary> + /// The resst client connection pool + /// </summary> + protected RestClientPool ClientPool { get; } + + private readonly Dictionary<string, LoginClaim> ClaimStore; + private readonly Dictionary<string, OAuthAccessState> AuthorizationStore; + private readonly IValidator<LoginClaim> ClaimValidator; + private readonly IValidator<string> NonceValidator; + private readonly IValidator<AccountData> AccountDataValidator; + + public 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<LoginClaim> GetClaimValidator() + { + InlineValidator<LoginClaim> 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<string> GetNonceValidator() + { + InlineValidator<string> 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; + } + + /// <summary> + /// Invoked by the constructor during rest client initlialization + /// </summary> + /// <param name="client">The new client to be configured</param> + protected virtual void StaticClientPoolInitializer(RestClient client) + { + client.AddDefaultHeader("accept", HttpHelpers.GetContentTypeString(ContentType.Json)); + client.UseSerializer<RestSharp.Serializers.Json.SystemTextJsonSerializer>(); + } + + protected virtual void OnBeforeGetToken(HttpEntity entity, string code, RestRequest state) { } + + /// <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> + /// <param name="cancellationToken"></param> + protected async Task<OAuthAccessState?> 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<OAuthAccessState> response = await client.Resource.ExecuteAsync<OAuthAccessState>(request, cancellationToken: cancellationToken); + //Make sure successfull, if so return the access token to store + return response.IsSuccessful && response.Data != null ? response.Data : null; + } + + /// <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> + /// <param name="cancellationToken"></param> + 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); + + class LoginClaim : ICacheable, INonce + { + [JsonPropertyName("public_key")] + public string? PublicKey { get; set; } + [JsonPropertyName("browser_id")] + public string? ClientId { get; set; } + + /// <summary> + /// The raw OAuth flow state parameter the client must decrypt before + /// navigating to remote authentication source + /// </summary> + [JsonIgnore] + public ReadOnlyMemory<byte> RawNonce { get; private set; } + [JsonIgnore] + DateTime ICacheable.Expires { get; set; } + bool IEquatable<ICacheable>.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<byte> buffer) + { + RandomHash.GetRandomBytes(buffer); + //Store copy + RawNonce = buffer.ToArray(); + } + + bool INonce.VerifyNonce(ReadOnlySpan<byte> 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<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()) + { + //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<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")) + { + 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<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; + } + + //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<char> scheme, ReadOnlySpan<char> redirectAuthority, Encoding enc) + { + //Char buffer for base32 and url building + using UnsafeMemoryHandle<byte> buffer = Memory.UnsafeAlloc<byte>(8192, true); + //get bin buffer slice + Span<byte> binBuffer = buffer.Span[1024..]; + + ReadOnlySpan<char> url; + { + //Get char buffer slice and cast to char + Span<char> charBuf = MemoryMarshal.Cast<byte, char>(buffer.Span[..1024]); + //buffer writer for easier syntax + ForwardOnlyWriter<char> 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<byte> encryptionBuffer = binBuffer[1024..]; + Span<byte> 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 new file mode 100644 index 0000000..cb2406c --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs @@ -0,0 +1,36 @@ +/* +* 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 new file mode 100644 index 0000000..0781d78 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj @@ -0,0 +1,50 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <Authors>Vaughn Nugent</Authors> + <Product>SocialOauth</Product> + <Version>1.0.1.5</Version> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + <PackageProjectUrl>https://www.vaughnnugent.com/resources</PackageProjectUrl> + <AssemblyName>SocialOauth</AssemblyName> + <Platforms>AnyCPU;x64</Platforms> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="FluentValidation" Version="11.3.0" /> + <PackageReference Include="RestSharp" Version="108.0.2" /> + </ItemGroup> + + <!-- Resolve nuget dll files and store them in the output dir --> + <PropertyGroup> + <!--Enable dynamic loading--> + <EnableDynamicLoading>true</EnableDynamicLoading> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\Extensions\VNLib.Plugins.Extensions.Loading\VNLib.Plugins.Extensions.Loading.csproj" /> + <ProjectReference Include="..\..\Extensions\VNLib.Plugins.Extensions.Validation\VNLib.Plugins.Extensions.Validation.csproj" /> + <ProjectReference Include="..\..\PluginBase\VNLib.Plugins.PluginBase.csproj" /> + <ProjectReference Include="..\..\VNLib.Net.Rest.Client\VNLib.Net.Rest.Client.csproj" /> + </ItemGroup> + + <ItemGroup> + <None Update="SocialOauth.json"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + </ItemGroup> + + <Target Name="PostBuild" AfterTargets="PostBuildEvent"> + <Exec Command="start xcopy "$(TargetDir)" "F:\Programming\Web Plugins\DevPlugins\$(TargetName)" /E /Y /R" /> + </Target> + +</Project> diff --git a/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs b/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs new file mode 100644 index 0000000..7ebb37e --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs @@ -0,0 +1,74 @@ +/* +* 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<AccountData> + { + 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 new file mode 100644 index 0000000..86893c5 --- /dev/null +++ b/VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs @@ -0,0 +1,60 @@ +/* +* 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<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"); + + 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(); + } + } +} |