diff options
author | vman <public@vaughnnugent.com> | 2022-11-18 17:43:57 -0500 |
---|---|---|
committer | vman <public@vaughnnugent.com> | 2022-11-18 17:43:57 -0500 |
commit | ef98ef0329d6ee8cec7f040f6c472dc1ea68e8dd (patch) | |
tree | 9be4b437895534f1f63b3a281e9e92c2a4a10421 /Plugins/OAuth2ClientApplications | |
parent | 8b09e20f6dbaf7644fc64833d7d8eeda4b576ad9 (diff) |
Add project files.
Diffstat (limited to 'Plugins/OAuth2ClientApplications')
4 files changed, 494 insertions, 0 deletions
diff --git a/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs b/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs new file mode 100644 index 0000000..757fdac --- /dev/null +++ b/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs @@ -0,0 +1,359 @@ +using System; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Oauth.Applications; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Sql; +using static VNLib.Plugins.Essentials.Statics; + +namespace OAuth2ClientApplications.Endpoints +{ + [ConfigurationName("applications")] + internal sealed class ApplicationEndpoint : ProtectedWebEndpoint + { + private class ApplicationMessage + { + [JsonPropertyName("name")] + public string? ApplicationName { get; set; } + [JsonPropertyName("description")] + public string? Description { get; set; } + [JsonPropertyName("client_id")] + public string? ClientID { get; set; } + [JsonPropertyName("raw_secret")] + public string? RawSecret { get; set; } + [JsonPropertyName("Id")] + public string? ApplicationID { get; set; } + [JsonPropertyName("permissions")] + public string? Permissions { get; set; } + } + + private readonly Applications Applications; + private readonly int MaxAppsPerUser; + private readonly string MaxAppOverloadMessage; + private readonly Task<JsonDocument?> JwtSigningKey; + + private static readonly UserAppValidator Validator = new(); + + public ApplicationEndpoint(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) + { + //Get configuration variables from plugin + string? path = config["path"].GetString(); + MaxAppsPerUser = config["max_apps_per_user"].GetInt32(); + + InitPathAndLog(path, plugin.Log); + //Load apps + Applications = new(plugin.GetContextOptions(), plugin.GetPasswords()); + + //Complie overload message + MaxAppOverloadMessage = $"You have reached the limit of {MaxAppsPerUser} applications, this application cannot be created"; + + //Get jwt signing key + JwtSigningKey = plugin.TryGetSecretAsync("jwt_signing_key").ContinueWith(s => s.Result == null ? null : JsonDocument.Parse(s.Result)); + } + + protected override async ValueTask<VfReturnType> GetAsync(HttpEntity ev) + { + //Try to get a single application from the database + + //Get a single specific application from an appid + if (ev.QueryArgs.TryGetNonEmptyValue("Id", out string? appid)) + { + appid = ValidatorExtensions.OnlyAlphaRegx.Replace(appid, ""); + //Execute get single app + UserApplication? singeApp = await Applications.GetSingleAsync(appid, ev.Session.UserID); + if (singeApp == null) + { + ev.CloseResponse(HttpStatusCode.NotFound); + return VfReturnType.VirtualSkip; + } + ev.CloseResponseJson(HttpStatusCode.OK, singeApp); + return VfReturnType.VirtualSkip; + } + //Process a "get all" + else + { + //Create list to store all applications + List<UserApplication> applications = Applications.ListRental.Rent(); + try + { + //Get all applications to fill the list + _ = await Applications.GetCollectionAsync(applications, ev.Session.UserID, MaxAppsPerUser); + //Write response (will convert json as needed before releasing the list) + ev.CloseResponseJson(HttpStatusCode.OK, applications); + return VfReturnType.VirtualSkip; + } + finally + { + //Return the list + Applications.ListRental.Return(applications); + } + } + } + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + //Default response + WebMessage webm = new(); + //Oauth is only available for local accounts + if (!entity.Session.HasLocalAccount()) + { + webm.Result = "OAuth is only available for internal user accounts"; + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + if (entity.QueryArgs.IsArgumentSet("action", "create")) + { + return await CreateAppAsync(entity); + } + + //Update the application secret + else if (entity.QueryArgs.IsArgumentSet("action", "secret")) + { + using JsonDocument? update = await entity.GetJsonFromFileAsync(); + + if(webm.Assert(update != null, "Invalid request")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Update message will include a challenge and an app id + string? appId = update.RootElement.GetPropString("Id"); + string? challenge = update.RootElement.GetPropString("challenge"); + + if (string.IsNullOrWhiteSpace(appId)) + { + return VfReturnType.NotFound; + } + + /* + * A secret update requires a client challenge because + * it can log-out active sessions and break access to + * other applications + */ + + if (string.IsNullOrWhiteSpace(challenge) || !entity.Session.VerifyChallenge(challenge)) + { + //return unauthorized + webm.Result = "Please check your password"; + entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm); + return VfReturnType.VirtualSkip; + } + + //Update the app's secret + using PrivateString? secret = await Applications.UpdateSecretAsync(entity.Session.UserID, appId); + + if (webm.Assert(secret != null, "Failed to update the application secret")) + { + entity.CloseResponseJson(HttpStatusCode.InternalServerError, webm); + return VfReturnType.VirtualSkip; + } + + /* + * We must return the secret to the user. + * + * The PrivateString must be casted and serialized + * while the using statment is in scope + */ + ApplicationMessage result = new() + { + ApplicationID = appId, + //Send raw secret + RawSecret = (string)secret + }; + //Must write response while password is in scope + entity.CloseResponseJson(HttpStatusCode.OK, result); + return VfReturnType.VirtualSkip; + } + else if (entity.QueryArgs.IsArgumentSet("action", "delete")) + { + using JsonDocument? update = await entity.GetJsonFromFileAsync(); + + if(webm.Assert(update != null, "Invalid request")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Update message will include a challenge and an app id + string? appId = update.RootElement.GetPropString("Id"); + string? challenge = update.RootElement.GetPropString("challenge"); + + if (string.IsNullOrWhiteSpace(appId)) + { + return VfReturnType.NotFound; + } + + /* + * A secret update requires a client challenge because + * it can log-out active sessions and break access to + * other applications + */ + if (string.IsNullOrWhiteSpace(challenge) || !entity.Session.VerifyChallenge(challenge)) + { + webm.Result = "Please check your password"; + //return unauthorized + entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm); + return VfReturnType.VirtualSkip; + } + //Try to delete the app + if (await Applications.DeleteAsync(appId, entity.Session.UserID)) + { + entity.CloseResponse(HttpStatusCode.NoContent); + return VfReturnType.VirtualSkip; + } + } + else + { + webm.Result = "The update type specified is not defined"; + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + return VfReturnType.BadRequest; + } + protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Oauth is only available for local accounts + if (!entity.Session.HasLocalAccount()) + { + webm.Result = "OAuth is only available for internal user accounts"; + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + //Get the application from client + UserApplication? app = await entity.GetJsonFromFileAsync<UserApplication>(SR_OPTIONS); + + if (webm.Assert(app != null, "Application is empty")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, app); + return VfReturnType.VirtualSkip; + } + + //set user-id + app.UserId = entity.Session.UserID; + //remove permissions + app.Permissions = null; + + //perform validation on the application update (should remove unused fields) + if (!Validator.Validate(app, webm)) + { + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + + //Update the app's meta + if (await Applications.UpdateAsync(app)) + { + //Send the app to the client + entity.CloseResponse(HttpStatusCode.NoContent); + return VfReturnType.VirtualSkip; + } + + //The app was not found and could not be updated + return VfReturnType.NotFound; + } + + private async ValueTask<VfReturnType> CreateAppAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Get the application from client + UserApplication? newApp = await entity.GetJsonFromFileAsync<UserApplication>(SR_OPTIONS); + + if (webm.Assert(newApp != null, "Application is empty")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate the new application + if (!Validator.Validate(newApp, webm)) + { + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + + //If no premissions are specified, set to "none" + if (string.IsNullOrWhiteSpace(newApp.Permissions)) + { + newApp.Permissions = "none"; + } + + //See if the user has enough room for more apps + long appCount = await Applications.GetCountAsync(entity.Session.UserID); + + if (appCount == -1) + { + webm.Result = $"There was a server error during creation of your application"; + Log.Error("There was an error retreiving the number of applications for user {id}", entity.Session.UserID); + entity.CloseResponseJson(HttpStatusCode.InternalServerError, webm); + return VfReturnType.VirtualSkip; + } + if (webm.Assert(appCount < MaxAppsPerUser, MaxAppOverloadMessage)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Parse permission string an re-build it to clean it up + newApp.Permissions = ParsePermissions(newApp.Permissions); + //Set user-id + newApp.UserId = entity.Session.UserID; + //Create the new application + if (!await Applications.CreateAsync(newApp)) + { + webm.Result = "The was an issue creating your application"; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Make sure to dispose the secret + using PrivateString secret = newApp.RawSecret!; + + //Success, now respond to the client with the new app information + ApplicationMessage mess = new() + { + ApplicationID = newApp.Id, + ApplicationName = newApp.AppName, + RawSecret = (string)secret, + ClientID = newApp.ClientId, + Description = newApp.AppDescription + }; + + //Must write response while the secret is in scope + entity.CloseResponseJson(HttpStatusCode.Created, mess); + return VfReturnType.VirtualSkip; + } + + private static string ParsePermissions(string permissions) + { + StringBuilder builder = new(); + //Local function for splitting permissions + static void SplitCb(ReadOnlySpan<char> permission, StringBuilder builder) + { + builder.Append(permission); + builder.Append(','); + } + //Split permissions at comma and clean up the entires + permissions.AsSpan().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries, SplitCb, builder); + //return the string + return builder.ToString(); + } + } +}
\ No newline at end of file diff --git a/Plugins/OAuth2ClientApplications/Endpoints/UserAppValidator.cs b/Plugins/OAuth2ClientApplications/Endpoints/UserAppValidator.cs new file mode 100644 index 0000000..dc865e9 --- /dev/null +++ b/Plugins/OAuth2ClientApplications/Endpoints/UserAppValidator.cs @@ -0,0 +1,43 @@ +using FluentValidation; +using FluentValidation.Results; + +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Essentials.Oauth.Applications; + +namespace OAuth2ClientApplications.Endpoints +{ + internal class UserAppValidator : AbstractValidator<UserApplication> + { + public UserAppValidator() + { + //Name rules + RuleFor(p => p.AppName) + .Length(1, 50) + .WithName("App name") + .SpecialCharacters() + .WithName("App name"); + //Description rules + RuleFor(app => app.AppDescription) + .SpecialCharacters() + .WithName("Description") + .MaximumLength(100) + .WithName("Description"); + RuleFor(app => app.Permissions) + .MaximumLength(100) + .SpecialCharacters() + .WithMessage("Invalid permissions"); + } + + public override ValidationResult Validate(ValidationContext<UserApplication> context) + { + //Get a ref to the app + UserApplication app = context.InstanceToValidate; + //remove unused fields + app.ClientId = null; + app.SecretHash = null; + //validate the rest of the app + return base.Validate(context); + } + } + +}
\ No newline at end of file diff --git a/Plugins/OAuth2ClientApplications/OAuth2ClientApplications.csproj b/Plugins/OAuth2ClientApplications/OAuth2ClientApplications.csproj new file mode 100644 index 0000000..bbb6deb --- /dev/null +++ b/Plugins/OAuth2ClientApplications/OAuth2ClientApplications.csproj @@ -0,0 +1,51 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + <Company>Vaughn Nugent</Company> + <Authors>Vaughn Nugent</Authors> + <AssemblyVersion>1.0.2.1</AssemblyVersion> + <FileVersion>1.0.2.1</FileVersion> + <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl> + <Version>1.0.2.1</Version> + <Platforms>AnyCPU;x64</Platforms> + </PropertyGroup> + + <!-- Resolve nuget dll files and store them in the output dir --> + <PropertyGroup> + <!--Enable dynamic loading--> + <EnableDynamicLoading>true</EnableDynamicLoading> + <Nullable>enable</Nullable> + </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> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\..\VNLib\Utils\src\VNLib.Utils.csproj" /> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Data\VNLib.Plugins.Extensions.Data.csproj" /> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading.Sql\VNLib.Plugins.Extensions.Loading.Sql.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="..\..\Libs\VNLib.Plugins.Essentials.Oauth\VNLib.Plugins.Essentials.Oauth.csproj" /> + </ItemGroup> + + <ItemGroup> + <None Update="OAuth2ClientApplications.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/Plugins/OAuth2ClientApplications/OAuth2ClientAppsEntryPoint.cs b/Plugins/OAuth2ClientApplications/OAuth2ClientAppsEntryPoint.cs new file mode 100644 index 0000000..b2354e5 --- /dev/null +++ b/Plugins/OAuth2ClientApplications/OAuth2ClientAppsEntryPoint.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +using OAuth2ClientApplications.Endpoints; + +using VNLib.Utils.Logging; +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading.Routing; + +namespace OAuth2ClientApplications +{ + public class OAuth2ClientAppsEntryPoint : PluginBase + { + public override string PluginName => "OAuth2.ClientApps"; + + protected override void OnLoad() + { + try + { + //Route the applications endpoint + this.Route<ApplicationEndpoint>(); + + Log.Information("Plugin 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(); + } + } +} |