diff options
Diffstat (limited to 'Plugins/OAuth2ClientApplications/src')
4 files changed, 570 insertions, 0 deletions
diff --git a/Plugins/OAuth2ClientApplications/src/Endpoints/ApplicationEndpoint.cs b/Plugins/OAuth2ClientApplications/src/Endpoints/ApplicationEndpoint.cs new file mode 100644 index 0000000..1eb2371 --- /dev/null +++ b/Plugins/OAuth2ClientApplications/src/Endpoints/ApplicationEndpoint.cs @@ -0,0 +1,380 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: OAuth2ClientApplications +* File: ApplicationEndpoint.cs +* +* ApplicationEndpoint.cs is part of OAuth2ClientApplications which is part of the larger +* VNLib collection of libraries and utilities. +* +* OAuth2ClientApplications is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* OAuth2ClientApplications 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with OAuth2ClientApplications. If not, see http://www.gnu.org/licenses/. +*/ + +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 sealed 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 ApplicationStore Applications; + private readonly int MaxAppsPerUser; + private readonly string MaxAppOverloadMessage; + + 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"; + } + + 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/src/Endpoints/UserAppValidator.cs b/Plugins/OAuth2ClientApplications/src/Endpoints/UserAppValidator.cs new file mode 100644 index 0000000..46051e9 --- /dev/null +++ b/Plugins/OAuth2ClientApplications/src/Endpoints/UserAppValidator.cs @@ -0,0 +1,67 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: OAuth2ClientApplications +* File: UserAppValidator.cs +* +* UserAppValidator.cs is part of OAuth2ClientApplications which is part of the larger +* VNLib collection of libraries and utilities. +* +* OAuth2ClientApplications is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* OAuth2ClientApplications 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with OAuth2ClientApplications. If not, see http://www.gnu.org/licenses/. +*/ + +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/src/OAuth2ClientApplications.csproj b/Plugins/OAuth2ClientApplications/src/OAuth2ClientApplications.csproj new file mode 100644 index 0000000..6275a42 --- /dev/null +++ b/Plugins/OAuth2ClientApplications/src/OAuth2ClientApplications.csproj @@ -0,0 +1,58 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <Nullable>enable</Nullable> + <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>https://www.vaughnnugent.com/resources</PackageProjectUrl> + <Version>1.0.2.1</Version> + + </PropertyGroup> + + <!-- Resolve nuget dll files and store them in the output dir --> + <PropertyGroup> + <!--Enable dynamic loading--> + <EnableDynamicLoading>true</EnableDynamicLoading> + <Private>false</Private> + <GenerateDocumentationFile>False</GenerateDocumentationFile> + <AnalysisLevel>latest-all</AnalysisLevel> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> + <Deterministic>False</Deterministic> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'"> + <Deterministic>False</Deterministic> + </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.4.0" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\..\Extensions\lib\VNLib.Plugins.Extensions.Loading.Sql\src\VNLib.Plugins.Extensions.Loading.Sql.csproj" /> + <ProjectReference Include="..\..\..\..\Extensions\lib\VNLib.Plugins.Extensions.Loading\src\VNLib.Plugins.Extensions.Loading.csproj" /> + <ProjectReference Include="..\..\..\..\Extensions\lib\VNLib.Plugins.Extensions.Validation\src\VNLib.Plugins.Extensions.Validation.csproj" /> + <ProjectReference Include="..\..\..\Libs\VNLib.Plugins.Essentials.Oauth\src\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\vnlib\devplugins\$(TargetName)" /E /Y /R" /> + </Target> + +</Project> diff --git a/Plugins/OAuth2ClientApplications/src/OAuth2ClientAppsEntryPoint.cs b/Plugins/OAuth2ClientApplications/src/OAuth2ClientAppsEntryPoint.cs new file mode 100644 index 0000000..5d257a1 --- /dev/null +++ b/Plugins/OAuth2ClientApplications/src/OAuth2ClientAppsEntryPoint.cs @@ -0,0 +1,65 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: OAuth2ClientApplications +* File: OAuth2ClientAppsEntryPoint.cs +* +* OAuth2ClientAppsEntryPoint.cs is part of OAuth2ClientApplications which is part of the larger +* VNLib collection of libraries and utilities. +* +* OAuth2ClientApplications is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* OAuth2ClientApplications 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with OAuth2ClientApplications. If not, see http://www.gnu.org/licenses/. +*/ + +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 sealed 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(); + } + } +} |