From ef98ef0329d6ee8cec7f040f6c472dc1ea68e8dd Mon Sep 17 00:00:00 2001 From: vman Date: Fri, 18 Nov 2022 17:43:57 -0500 Subject: Add project files. --- .../Endpoints/ApplicationEndpoint.cs | 359 +++++++++++++++++++++ .../Endpoints/UserAppValidator.cs | 43 +++ .../OAuth2ClientApplications.csproj | 51 +++ .../OAuth2ClientAppsEntryPoint.cs | 41 +++ 4 files changed, 494 insertions(+) create mode 100644 Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs create mode 100644 Plugins/OAuth2ClientApplications/Endpoints/UserAppValidator.cs create mode 100644 Plugins/OAuth2ClientApplications/OAuth2ClientApplications.csproj create mode 100644 Plugins/OAuth2ClientApplications/OAuth2ClientAppsEntryPoint.cs (limited to 'Plugins/OAuth2ClientApplications') 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 JwtSigningKey; + + private static readonly UserAppValidator Validator = new(); + + public ApplicationEndpoint(PluginBase plugin, IReadOnlyDictionary 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 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 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 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 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(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 CreateAppAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Get the application from client + UserApplication? newApp = await entity.GetJsonFromFileAsync(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 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 + { + 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 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 @@ + + + + net6.0 + Copyright © 2022 Vaughn Nugent + Vaughn Nugent + Vaughn Nugent + 1.0.2.1 + 1.0.2.1 + www.vaughnnugent.com/resources + 1.0.2.1 + AnyCPU;x64 + + + + + + true + enable + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + Always + + + + + + + + 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(); + + 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(); + } + } +} -- cgit