diff options
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.SocialOauth')
8 files changed, 417 insertions, 354 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs index 8a7aea3..18f4081 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs @@ -1,12 +1,12 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth -* File: ClientAccessTokenState.cs +* File: OAuthAccessState.cs * -* ClientAccessTokenState.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. +* OAuthAccessState.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 @@ -23,17 +23,11 @@ */ 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 + public sealed class OAuthAccessState : IOAuthAccessState { ///<inheritdoc/> [JsonPropertyName("access_token")] @@ -50,36 +44,5 @@ namespace VNLib.Plugins.Essentials.SocialOauth ///<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(StringComparison.Ordinal); - void ICacheable.Evicted() - { - MemoryUtil.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/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs index 3466ad0..2f99693 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -24,7 +24,6 @@ using System; using System.Linq; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -32,13 +31,13 @@ using System.Collections.Generic; using RestSharp; -using VNLib.Net.Rest.Client; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; 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 { @@ -46,13 +45,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints [ConfigurationName("auth0")] internal sealed class Auth0 : SocialOauthBase { - - protected override OauthClientConfig Config { get; } - - private readonly Task<ReadOnlyJsonWebKey[]> Auth0VerificationJwk; - public Auth0(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) : base() + public Auth0(PluginBase plugin, IConfigScope config) : base(plugin, config) { string keyUrl = config["key_url"].GetString() ?? throw new KeyNotFoundException("Missing Auth0 'key_url' from config"); @@ -60,31 +55,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints //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.ObserveTask(async () => - { - //Get id/secret - Task<SecretResult?> secretTask = plugin.TryGetSecretAsync("auth0_client_secret"); - Task<SecretResult?> 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); } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs index a701bdf..93cb22d 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -25,9 +25,7 @@ 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; @@ -37,41 +35,14 @@ 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<string, JsonElement> config) : base() + public DiscordOauth(PluginBase plugin, IConfigScope config) : base(plugin, config) { - Config = new("discord", config) - { - Passwords = plugin.GetPasswords(), - Users = plugin.GetUserManager(), - }; - - InitPathAndLog(Config.EndpointPath, plugin.Log); - - //Load secrets - _ = plugin.ObserveTask(async () => - { - //Get id/secret - Task<SecretResult?> clientIdTask = plugin.TryGetSecretAsync("discord_client_id"); - Task<SecretResult?> 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); } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs index 7e8c576..3d6fa30 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -23,9 +23,7 @@ */ 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; @@ -37,7 +35,6 @@ 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 { @@ -47,38 +44,11 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints 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() - { - + public GitHubOauth(PluginBase plugin, IConfigScope config) : base(plugin, config) + { 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.ObserveTask(async () => - { - //Get id/secret - Task<SecretResult?> clientIdTask = plugin.TryGetSecretAsync("github_client_id"); - Task<SecretResult?> 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) diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs index 9caf705..5b0d8ed 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -23,31 +23,36 @@ */ using System; -using System.Text.Json; +using System.Threading.Tasks; using System.Collections.Generic; using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Accounts; - +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Users; namespace VNLib.Plugins.Essentials.SocialOauth { - public sealed class OauthClientConfig + public sealed class OauthClientConfig : IAsyncConfigurable { + private readonly string ConfigName; + - public OauthClientConfig(string configName, IReadOnlyDictionary<string, JsonElement> config) + public OauthClientConfig(PluginBase plugin, IConfigScope config) { - EndpointPath = config["path"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'path' in config {configName}"); + ConfigName = config.ScopeName; + + 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}"); + 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}"); + 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); @@ -58,12 +63,31 @@ namespace VNLib.Plugins.Essentials.SocialOauth LoginNonceLifetime = config["valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); NonceByteSize = config["nonce_size"].GetUInt32(); RandomPasswordSize = config["password_size"].GetInt32(); + InitClaimValidFor = config["claim_valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); + + Users = plugin.GetOrCreateSingleton<UserManager>(); + Passwords = plugin.GetPasswords(); } - - public string ClientID { get; set; } = string.Empty; + public async Task ConfigureServiceAsync(PluginBase plugin) + { + //Get id/secret + Task<SecretResult?> clientIdTask = plugin.TryGetSecretAsync($"{ConfigName}_client_id"); + Task<SecretResult?> secretTask = plugin.TryGetSecretAsync($"{ConfigName}_client_secret"); + + await Task.WhenAll(secretTask, clientIdTask); + + using SecretResult? secret = await secretTask; + using SecretResult? clientId = await clientIdTask; + + ClientID = clientId?.Result.ToString() ?? throw new KeyNotFoundException($"Missing {ConfigName} client id from config or vault"); + ClientSecret = secret?.Result.ToString() ?? throw new KeyNotFoundException($"Missing the {ConfigName} client secret from config or vault"); + } + + + public string ClientID { get; private set; } = string.Empty; - public string ClientSecret { get; set; } = string.Empty; + public string ClientSecret { get; private set; } = string.Empty; /// <summary> @@ -92,9 +116,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// <summary> /// The user store to create/get users from /// </summary> - public IUserManager Users { get; init; } + public IUserManager Users { get; } - public PasswordHashing Passwords { get; init; } + public IPasswordHashingProvider Passwords { get; } /// <summary> /// The endpoint route/path @@ -122,5 +146,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// The size (in bytes) of the random password generated for new users /// </summary> public int RandomPasswordSize { get; } + + /// <summary> + /// The initial time the login claim is valid for + /// </summary> + public TimeSpan InitClaimValidFor { get; } } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs index d0f7a84..05152b2 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -23,7 +23,6 @@ */ using System; -using System.Collections.Generic; using VNLib.Utils.Logging; using VNLib.Plugins.Essentials.SocialOauth.Endpoints; @@ -39,32 +38,25 @@ namespace VNLib.Plugins.Essentials.SocialOauth protected override void OnLoad() { - try + //Get the discord oauth config from the config file + if (this.HasConfigForType<DiscordOauth>()) { - //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"); - } + //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"); } - catch(KeyNotFoundException kne) + + if (this.HasConfigForType<Auth0>()) { - Log.Error("Missing required configuration variables, {reason}", kne.Message); + //Add the auth0 login endpoint + this.Route<Auth0>(); + Log.Information("Auth0 social OAuth authentication loaded"); } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs index 73c2ab5..2ad3a8e 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -25,6 +25,7 @@ using System; using System.Net; using System.Text; +using System.Buffers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -32,6 +33,7 @@ using System.Collections.Generic; using System.Security.Cryptography; using System.Text.Json.Serialization; using System.Runtime.InteropServices; +using System.Diagnostics.CodeAnalysis; using FluentValidation; @@ -39,17 +41,18 @@ using RestSharp; using VNLib.Net.Http; using VNLib.Net.Rest.Client; using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; 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; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; namespace VNLib.Plugins.Essentials.SocialOauth { @@ -62,11 +65,17 @@ namespace VNLib.Plugins.Essentials.SocialOauth const string AUTH_ERROR_MESSAGE = "You have no pending authentication requests."; const string AUTH_GRANT_SESSION_NAME = "auth"; + const string SESSION_SIG_KEY_NAME = "soa.sig"; + const string SESSION_TOKEN_KEY_NAME = "soa.tkn"; + const string CLAIM_COOKIE_NAME = "extern-claim"; + const int SIGNING_KEY_SIZE = 32; + + private static HMAC GetSigningAlg(byte[] key) => new HMACSHA256(key); /// <summary> /// The client configuration struct passed during base class construction /// </summary> - protected abstract OauthClientConfig Config { get; } + protected virtual OauthClientConfig Config { get; } ///<inheritdoc/> protected override ProtectionSettings EndpointProtectionSettings { get; } = new() @@ -82,17 +91,13 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// 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; - protected SocialOauthBase() + protected SocialOauthBase(PluginBase plugin, IConfigScope config) { - ClaimStore = new(StringComparer.OrdinalIgnoreCase); - AuthorizationStore = new(StringComparer.OrdinalIgnoreCase); ClaimValidator = GetClaimValidator(); NonceValidator = GetNonceValidator(); AccountDataValidator = new AccountDataValidator(); @@ -108,6 +113,12 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Configure rest client to comunications to main discord api ClientPool = new(10, poolOptions, StaticClientPoolInitializer); + + //Get the configuration element for the derrived type + Config = plugin.CreateService<OauthClientConfig>(config); + + //Init endpoint + InitPathAndLog(Config.EndpointPath, plugin.Log); } private static IValidator<LoginClaim> GetClaimValidator() @@ -133,6 +144,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth return val; } + ///<inheritdoc/> protected override ERRNO PreProccess(HttpEntity entity) { if (!base.PreProccess(entity)) @@ -150,7 +162,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth } //Make sure the user is not logged in - return !(entity.LoginCookieMatches() || entity.TokenMatches()); + return !entity.IsClientAuthorized(AuthorzationCheckLevel.Any); } /// <summary> @@ -176,8 +188,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// 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) + protected virtual 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); @@ -207,7 +218,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// <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) @@ -217,45 +227,94 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// <returns></returns> protected abstract Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation); - sealed class LoginClaim : ICacheable, INonce + sealed class LoginClaim : IClientSecInfo { [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 - MemoryUtil.UnsafeZeroMemory(RawNonce); - } + [JsonPropertyName("exp")] + public long ExpirationSeconds { get; set; } - public override bool Equals(object? obj) + [JsonPropertyName("iat")] + public long IssuedAtTime { get; set; } + + [JsonPropertyName("nonce")] + public string? Nonce { get; set; } + + public void ComputeNonce(int nonceSize) { - return obj is LoginClaim otherClaim && this.PublicKey!.Equals(otherClaim.PublicKey, StringComparison.Ordinal); + byte[] buffer = ArrayPool<byte>.Shared.Rent(nonceSize); + try + { + Span<byte> nonce = buffer.AsSpan(0, nonceSize); + + //get random data + RandomHash.GetRandomBytes(nonce); + + //Encode nonce + Nonce = VnEncoding.ToBase32String(nonce); + } + finally + { + MemoryUtil.InitializeBlock(buffer.AsSpan()); + ArrayPool<byte>.Shared.Return(buffer); + } } - public override int GetHashCode() => PublicKey!.GetHashCode(); + } + + /* + * 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(); - void INonce.ComputeNonce(Span<byte> buffer) + //Get the login message + LoginClaim? claim = await entity.GetJsonFromFileAsync<LoginClaim>(); + + if (webm.Assert(claim != null, "Emtpy message body")) { - RandomHash.GetRandomBytes(buffer); - //Store copy - RawNonce = buffer.ToArray(); + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; } - bool INonce.VerifyNonce(ReadOnlySpan<byte> nonceBytes) + //Validate the message + if (!ClaimValidator.Validate(claim, webm)) { - return CryptographicOperations.FixedTimeEquals(RawNonce.Span, nonceBytes); + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; } + + //Configure the login claim + claim.IssuedAtTime = entity.RequestedTimeUtc.ToUnixTimeSeconds(); + + //Set expiration time in seconds + claim.ExpirationSeconds = entity.RequestedTimeUtc.Add(Config.InitClaimValidFor).ToUnixTimeMilliseconds(); + + //Set nonce + claim.ComputeNonce((int)Config.NonceByteSize); + + //Build the redirect uri + webm.Result = new LoginUriBuilder() + .WithEncoding(entity.Server.Encoding) + .WithUrl(entity.IsSecure ? "https" : "http", entity.Server.RequestUri.Authority, Path) + .WithNonce(claim.Nonce!) + .Build(Config) + .Encrypt(entity, claim); + + //Sign and set the claim cookie + SignAndSetCookie(entity, claim); + + webm.Success = true; + //Response + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; } /* @@ -272,65 +331,61 @@ namespace VNLib.Plugins.Essentials.SocialOauth { //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()) { + ClearClaimData(entity); //The connection was not a browser redirect entity.Redirect(RedirectType.Temporary, $"{Path}?result=bad_sec"); return VfReturnType.VirtualSkip; } - + //Try to get the claim from the state parameter - if (ClaimStore.TryGetOrEvictRecord(state, out LoginClaim? claim) < 1) + if (!VerifyAndGetClaim(entity, out LoginClaim? claim)) { + ClearClaimData(entity); 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; - } + //Confirm the nonce matches the claim + if (string.CompareOrdinal(claim.Nonce, state) != 0) + { + ClearClaimData(entity); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); + return VfReturnType.VirtualSkip; } - + //Exchange the OAuth code for a token (application specific) OAuthAccessState? token = await ExchangeCodeForTokenAsync(entity, code, entity.EventCancellation); - + //Token may be null - if(token == null) + if (token == null) { + ClearClaimData(entity); 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); + + //Create the new nonce + claim.ComputeNonce((int)Config.NonceByteSize); + + //Store access state in the user's session + entity.Session.SetObject(SESSION_TOKEN_KEY_NAME, token); + + //Sign and set cookie + SignAndSetCookie(entity, claim); + //Prepare redirect - entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={nonce}"); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={claim.Nonce}"); return VfReturnType.VirtualSkip; } //Check to see if there was an error code set if (entity.QueryArgs.TryGetNonEmptyValue("error", out string? errorCode)) { + ClearClaimData(entity); Log.Debug("{Type} error {err}:{des}", Config.AccountOrigin, errorCode, entity.QueryArgs["error_description"]); entity.Redirect(RedirectType.Temporary, $"{Path}?result=error"); return VfReturnType.VirtualSkip; @@ -358,6 +413,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Recover the nonce string? base32Nonce = request.RootElement.GetPropString("nonce"); + if(webm.Assert(base32Nonce != null, message: "Nonce parameter is required")) { entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); @@ -370,29 +426,30 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); return VfReturnType.VirtualSkip; } - + //Recover the access token - if (AuthorizationStore.TryGetOrEvictRecord(base32Nonce!, out OAuthAccessState? token) < 1) + bool cookieValid = VerifyAndGetClaim(entity, out LoginClaim? claim); + + if (webm.Assert(cookieValid, AUTH_ERROR_MESSAGE)) { - 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)) + + //We can clear the client's access claim + ClearClaimData(entity); + + //Confirm nonce matches the client's nonce string + bool nonceValid = string.CompareOrdinal(claim.Nonce, base32Nonce) == 0; + + if (webm.Assert(nonceValid, AUTH_ERROR_MESSAGE)) { entity.CloseResponse(webm); return VfReturnType.VirtualSkip; - } + } + + //Safe to recover the access token + IOAuthAccessState token = entity.Session.GetObject<OAuthAccessState>(SESSION_TOKEN_KEY_NAME); //get the user's login information (ie userid) UserLoginData? userLogin = await GetLoginDataAsync(token, entity.EventCancellation); @@ -430,12 +487,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth 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 - MemoryUtil.InitializeBlock(randomPass.AsSpan()); + using PrivateString passhash = Config.Passwords.GetRandomPassword(Config.RandomPasswordSize); try { //Create the user with the specified email address, minimum privilage level, and an empty password @@ -454,10 +508,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.CloseResponse(webm); return VfReturnType.VirtualSkip; } - finally - { - passhash.Dispose(); - } } else { @@ -467,12 +517,14 @@ namespace VNLib.Plugins.Essentials.SocialOauth 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) { @@ -490,14 +542,17 @@ namespace VNLib.Plugins.Essentials.SocialOauth try { //Generate authoization - webm.Token = entity.GenerateAuthorization(token.PublicKey!, token.ClientId!, user); + entity.GenerateAuthorization(claim, user, webm); + //Store the user current oauth information in the current session for others to digest entity.Session.SetObject($"{Config.AccountOrigin}.{AUTH_GRANT_SESSION_NAME}", token); + //Send the username back to the client webm.Result = new AccountData() { EmailAddress = user.EmailAddress, }; + //Set the success flag webm.Success = true; //Write to log @@ -520,7 +575,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth webm.Token = null; webm.Result = AUTH_ERROR_MESSAGE; webm.Success = false; + + //destroy any login data on failure entity.InvalidateLogin(); + Log.Error(uue); } finally @@ -530,52 +588,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth 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 @@ -584,52 +600,199 @@ namespace VNLib.Plugins.Essentials.SocialOauth * 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) + sealed class LoginUriBuilder { - //Char buffer for base32 and url building - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(8192, true); - //get bin buffer slice - Span<byte> binBuffer = buffer.Span[1024..]; - Span<byte> charBuffer = buffer.Span[..1024]; - - ReadOnlySpan<char> url; + private readonly IMemoryHandle<byte> _buffer; + + private Span<byte> _binBuffer => _buffer.Span[1024..]; + private Span<char> _charBuffer => MemoryMarshal.Cast<byte, char>(_buffer.Span[..1024]); + + private string? redirectUrl; + private string? nonce; + private Encoding _encoding; + + private int _urlCharPointer; + + public LoginUriBuilder() + { + //Alloc buffer + _buffer = MemoryUtil.SafeAllocNearestPage<byte>(8000, true); + + //Set default encoding + _encoding = Encoding.UTF8; + } + + public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path) { - //Get char buffer slice and cast to char - Span<char> charBuf = MemoryMarshal.Cast<byte, char>(charBuffer); //buffer writer for easier syntax - ForwardOnlyWriter<char> writer = new(charBuf); + ForwardOnlyWriter<char> writer = new(_charBuffer); //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); + writer.Append(authority); + 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(); + redirectUrl = Uri.EscapeDataString(writer.ToString()); + + return this; + } + + public LoginUriBuilder WithEncoding(Encoding encoding) + { + _encoding = encoding; + return this; + } + + public LoginUriBuilder WithNonce(string base32Nonce) + { + nonce = base32Nonce; + return this; + } + + public LoginUriBuilder Build(OauthClientConfig config) + { + //buffer writer for easier syntax + ForwardOnlyWriter<char> writer = new(_charBuffer); + //Append the config redirect path - writer.Append(Config.AccessCodeUrl.OriginalString); + writer.Append(config.AccessCodeUrl.OriginalString); //begin query arguments writer.Append("&client_id="); - writer.Append(Config.ClientID); + writer.Append(config.ClientID); //add the redirect url writer.Append("&redirect_uri="); - writer.Append(redirectFiltered); + writer.Append(redirectUrl); //Append the state parameter writer.Append("&state="); - writer.Append(base32Nonce); - url = writer.AsSpan(); + writer.Append(nonce); + + //Update url pointer + _urlCharPointer = writer.Written; + + return this; } - //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 = AccountUtil.TryEncryptClientData(pubKey, encodingBuffer[..byteCount], in encryptionBuffer); - //base64 encode the encrypted - return Convert.ToBase64String(encryptionBuffer[0..(int)count]); + + public string Encrypt(HttpEntity client, IClientSecInfo secInfo) + { + try + { + ReadOnlySpan<char> url = _charBuffer[.._urlCharPointer]; + + //Separate buffers + Span<byte> encryptionBuffer = _binBuffer[1024..]; + Span<byte> encodingBuffer = _binBuffer[..1024]; + + //Encode the url to binary + int byteCount = _encoding.GetBytes(url, encodingBuffer); + + //Encrypt the binary data + ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer); + + //base64 encode the encrypted + return Convert.ToBase64String(encryptionBuffer[0..(int)count]); + } + finally + { + _urlCharPointer = 0; + //Dispose buffer + _buffer.Dispose(); + } + } + + } + + private static bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim) + { + claim = null; + + //Try to get the cookie + if(!entity.Server.GetCookie(CLAIM_COOKIE_NAME, out string? cookieValue)) + { + return false; + } + + //Recover the signing key from the user's session + string sigKey = entity.Session[SESSION_SIG_KEY_NAME]; + byte[]? key = VnEncoding.FromBase32String(sigKey); + + if (key == null) + { + return false; + } + + try + { + //Try to parse the jwt + using JsonWebToken jwt = JsonWebToken.Parse(cookieValue); + + //Verify the jwt + using(HMAC alg = GetSigningAlg(key)) + { + if (!jwt.Verify(alg)) + { + return false; + } + } + + //Recover the clam from the jwt + claim = jwt.GetPayload<LoginClaim>(); + + //Verify the expiration time + return claim.ExpirationSeconds > entity.RequestedTimeUtc.ToUnixTimeSeconds(); + } + catch (FormatException) + { + return false; + } + finally + { + MemoryUtil.InitializeBlock(key.AsSpan()); + } + } + + private static void ClearClaimData(HttpEntity entity) + { + if (entity.Server.RequestCookies.ContainsKey(CLAIM_COOKIE_NAME)) + { + entity.Server.ExpireCookie(CLAIM_COOKIE_NAME); + } + + entity.Session[SESSION_SIG_KEY_NAME] = null!; + } + + private void SignAndSetCookie(HttpEntity entity, LoginClaim claim) + { + //Setup Jwt + using JsonWebToken jwt = new(); + + //Write claim body + jwt.WritePayload(claim); + + //Generate signing key + byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE); + + //Sign the jwt + using(HMAC alg = GetSigningAlg(sigKey)) + { + jwt.Sign(alg); + } + + //Build and set cookie + HttpCookie cookie = new(CLAIM_COOKIE_NAME, jwt.Compile()) + { + Secure = true, + HttpOnly = true, + ValidFor = Config.InitClaimValidFor, + SameSite = CookieSameSite.SameSite + }; + + entity.Server.SetCookie(in cookie); + + //Encode and store the signing key in the clien't session + entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase32String(sigKey); + + MemoryUtil.InitializeBlock(sigKey.AsSpan()); } } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs index 86893c5..3cf4e70 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -40,12 +40,16 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Validators */ public LoginMessageValidation() { - RuleFor(t => t.ClientID) + RuleFor(t => t.ClientId) .Length(10, 50) - .WithMessage("Your browser is not sending required security information"); + .WithMessage("Your browser is not sending required security information") + .IllegalCharacters() + .WithMessage("Your browser is not sending required security information"); RuleFor(t => t.ClientPublicKey) - .NotEmpty() + .Length (50, 1000) + .WithMessage("Your browser is not sending required security information") + .IllegalCharacters() .WithMessage("Your browser is not sending required security information"); //Password is only used for nonce tokens @@ -53,8 +57,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Validators RuleFor(t => t.LocalLanguage) .NotEmpty() + .WithMessage("Your language is not supported") + .AlphaNumericOnly() .WithMessage("Your language is not supported"); - RuleFor(t => t.LocalLanguage).AlphaNumericOnly(); } } } |