diff --git a/Elsa.sln b/Elsa.sln index c406dbfc1..4981d316e 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -345,6 +345,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ElsaDashboard.Samples.Blazo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ElsaDashboard.Samples.BlazorServer", "src\samples\dashboard\blazor\ElsaDashboard.Samples.BlazorServer\ElsaDashboard.Samples.BlazorServer.csproj", "{9A4B1C48-16FB-4A65-AE98-5CDE7BCB506C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.HttpEndpointSecurity", "src\samples\aspnet\Elsa.Samples.HttpEndpointSecurity\Elsa.Samples.HttpEndpointSecurity.csproj", "{82B115DA-E3D0-49D7-AD08-DE9387656756}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -782,6 +784,10 @@ Global {9A4B1C48-16FB-4A65-AE98-5CDE7BCB506C}.Debug|Any CPU.Build.0 = Debug|Any CPU {9A4B1C48-16FB-4A65-AE98-5CDE7BCB506C}.Release|Any CPU.ActiveCfg = Release|Any CPU {9A4B1C48-16FB-4A65-AE98-5CDE7BCB506C}.Release|Any CPU.Build.0 = Release|Any CPU + {82B115DA-E3D0-49D7-AD08-DE9387656756}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {82B115DA-E3D0-49D7-AD08-DE9387656756}.Debug|Any CPU.Build.0 = Debug|Any CPU + {82B115DA-E3D0-49D7-AD08-DE9387656756}.Release|Any CPU.ActiveCfg = Release|Any CPU + {82B115DA-E3D0-49D7-AD08-DE9387656756}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -929,6 +935,7 @@ Global {BC4B4F3F-6E2C-4736-AAB8-6DF78BFA3ACB} = {FC9F520F-BA51-4AD2-BFEE-EF787798E734} {DCB3C4DD-2B7D-44E9-A366-F36F4ABFF488} = {D86B94DC-A53C-4A67-A820-828DD359C49B} {9A4B1C48-16FB-4A65-AE98-5CDE7BCB506C} = {D86B94DC-A53C-4A67-A820-828DD359C49B} + {82B115DA-E3D0-49D7-AD08-DE9387656756} = {22E75696-6FE9-436A-9097-EE21C603F818} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8B0975FD-7050-48B0-88C5-48C33378E158} diff --git a/src/activities/Elsa.Activities.Http/Activities/HttpEndpoint/HttpEndpoint.cs b/src/activities/Elsa.Activities.Http/Activities/HttpEndpoint/HttpEndpoint.cs index fad530336..23b8a9095 100644 --- a/src/activities/Elsa.Activities.Http/Activities/HttpEndpoint/HttpEndpoint.cs +++ b/src/activities/Elsa.Activities.Http/Activities/HttpEndpoint/HttpEndpoint.cs @@ -56,6 +56,20 @@ namespace Elsa.Activities.Http [ActivityInput(Category = PropertyCategories.Advanced)] public Type? TargetType { get; set; } + [ActivityInput( + Hint = "Check to allow authenticated requests only", + SupportedSyntaxes = new[] { SyntaxNames.Literal, SyntaxNames.JavaScript, SyntaxNames.Liquid }, + Category = "Security" + )] + public bool Authorize { get; set; } + + [ActivityInput( + Hint = "Provide a policy to evaluate. If the policy fails, the request is forbidden.", + SupportedSyntaxes = new[] { SyntaxNames.Literal, SyntaxNames.JavaScript, SyntaxNames.Liquid }, + Category = "Security" + )] + public string? Policy { get; set; } + [ActivityOutput(Hint = "The received HTTP request.")] public HttpRequestModel? Output { get; set; } diff --git a/src/activities/Elsa.Activities.Http/Activities/HttpEndpoint/HttpEndpointExtensions.cs b/src/activities/Elsa.Activities.Http/Activities/HttpEndpoint/HttpEndpointExtensions.cs index 4c5e42a85..80b415a8c 100644 --- a/src/activities/Elsa.Activities.Http/Activities/HttpEndpoint/HttpEndpointExtensions.cs +++ b/src/activities/Elsa.Activities.Http/Activities/HttpEndpoint/HttpEndpointExtensions.cs @@ -45,5 +45,17 @@ namespace Elsa.Activities.Http public static ISetupActivity WithTargetType(this ISetupActivity activity, Func value) => activity.Set(x => x.TargetType, value).WithReadContent(); public static ISetupActivity WithTargetType(this ISetupActivity activity, Type? value) => activity.Set(x => x.TargetType, value).WithReadContent(); public static ISetupActivity WithTargetType(this ISetupActivity activity) => activity.Set(x => x.TargetType, typeof(T)).WithReadContent(); + + public static ISetupActivity WithAuthorize(this ISetupActivity activity, Func> value) => activity.Set(x => x.Authorize, value); + public static ISetupActivity WithAuthorize(this ISetupActivity activity, Func> value) => activity.Set(x => x.Authorize, value); + public static ISetupActivity WithAuthorize(this ISetupActivity activity, Func value) => activity.Set(x => x.Authorize, value); + public static ISetupActivity WithAuthorize(this ISetupActivity activity, Func value) => activity.Set(x => x.Authorize, value); + public static ISetupActivity WithAuthorize(this ISetupActivity activity, bool value = true) => activity.Set(x => x.Authorize, value); + + public static ISetupActivity WithPolicy(this ISetupActivity activity, Func> value) => activity.Set(x => x.Policy, value); + public static ISetupActivity WithPolicy(this ISetupActivity activity, Func> value) => activity.Set(x => x.Policy, value); + public static ISetupActivity WithPolicy(this ISetupActivity activity, Func value) => activity.Set(x => x.Policy, value); + public static ISetupActivity WithPolicy(this ISetupActivity activity, Func value) => activity.Set(x => x.Policy, value); + public static ISetupActivity WithPolicy(this ISetupActivity activity, string? value) => activity.Set(x => x.Policy, value); } } \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Http/Elsa.Activities.Http.csproj b/src/activities/Elsa.Activities.Http/Elsa.Activities.Http.csproj index dec7eb162..33f53b664 100644 --- a/src/activities/Elsa.Activities.Http/Elsa.Activities.Http.csproj +++ b/src/activities/Elsa.Activities.Http/Elsa.Activities.Http.csproj @@ -23,6 +23,7 @@ + diff --git a/src/activities/Elsa.Activities.Http/Endpoints/Signals/DispatchEndpoint.cs b/src/activities/Elsa.Activities.Http/Endpoints/Signals/DispatchEndpoint.cs index f67394460..82e46b5c6 100644 --- a/src/activities/Elsa.Activities.Http/Endpoints/Signals/DispatchEndpoint.cs +++ b/src/activities/Elsa.Activities.Http/Endpoints/Signals/DispatchEndpoint.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Elsa.Activities.Signaling.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Open.Linq.AsyncExtensions; @@ -8,6 +9,7 @@ namespace Elsa.Activities.Http.Endpoints.Signals [ApiController] [Route("signals/dispatch/{token}")] [Produces("application/json")] + [Authorize()] public class DispatchEndpoint : ControllerBase { private readonly ISignaler _signaler; diff --git a/src/activities/Elsa.Activities.Http/Extensions/ServiceCollectionExtensions.cs b/src/activities/Elsa.Activities.Http/Extensions/ServiceCollectionExtensions.cs index 794b55b6e..fdd39973f 100644 --- a/src/activities/Elsa.Activities.Http/Extensions/ServiceCollectionExtensions.cs +++ b/src/activities/Elsa.Activities.Http/Extensions/ServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using Elsa.Scripting.Liquid.Extensions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection @@ -33,6 +34,7 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton(); services.AddHttpClient(nameof(SendHttpRequest)); + services.AddAuthorizationCore(); services .AddSingleton() @@ -43,6 +45,7 @@ namespace Microsoft.Extensions.DependencyInjection .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService>().Value.HttpEndpointAuthorizationHandlerFactory(sp)) .AddBookmarkProvider() .AddHttpContextAccessor() .AddNotificationHandlers(typeof(ConfigureJavaScriptEngine)) diff --git a/src/activities/Elsa.Activities.Http/Middleware/HttpEndpointMiddleware.cs b/src/activities/Elsa.Activities.Http/Middleware/HttpEndpointMiddleware.cs index c0dd7e1ae..60132a6af 100644 --- a/src/activities/Elsa.Activities.Http/Middleware/HttpEndpointMiddleware.cs +++ b/src/activities/Elsa.Activities.Http/Middleware/HttpEndpointMiddleware.cs @@ -14,6 +14,7 @@ using Elsa.Models; using Elsa.Persistence; using Elsa.Services; using Elsa.Services.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Newtonsoft.Json; @@ -36,30 +37,19 @@ namespace Elsa.Activities.Http.Middleware IWorkflowInstanceStore workflowInstanceStore, IWorkflowRegistry workflowRegistry, IWorkflowBlueprintReflector workflowBlueprintReflector, + IHttpEndpointAuthorizationHandler authorizationHandler, IEnumerable contentParsers) { var basePath = options.Value.BasePath; + var path = GetPath(basePath, httpContext); + + if (path == null) + { + await _next(httpContext); + return; + } + var request = httpContext.Request; - - string path; - - // If a base path was configured, try to match against that first. - if (basePath != null) - { - // If no match, continue with the next middleware in the pipeline. - if (!request.Path.StartsWithSegments(basePath.Value, out _, out var remainingPath)) - { - await _next(httpContext); - return; - } - - path = remainingPath.Value.ToLowerInvariant(); - } - else - { - path = httpContext.Request.Path.Value.ToLowerInvariant(); - } - var cancellationToken = CancellationToken.None; // Prevent half-way request abortion (which also happens when WriteHttpResponse writes to the response). var method = httpContext.Request.Method!.ToLowerInvariant(); @@ -70,38 +60,15 @@ namespace Elsa.Activities.Http.Middleware var collectWorkflowsContext = new WorkflowsQuery(activityType, bookmark, correlationId, default, default, TenantId); var pendingWorkflows = await workflowLaunchpad.FindWorkflowsAsync(collectWorkflowsContext, cancellationToken).ToList(); - if (!pendingWorkflows.Any()) - { - // If a base path was configured, we are sure the requester tried to execute a workflow that doesn't exist. - // Therefore, sending a 404 response seems appropriate instead of continuing with any subsequent middlewares. - if (basePath != null) - { - httpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; - return; - } - - // If no base path was configured on the other hand, the request could be targeting anything else and should be handled by subsequent middlewares. - await _next(httpContext); + if (await HandleNoWorkflowsFoundAsync(httpContext, pendingWorkflows, basePath)) return; - } - if (pendingWorkflows.Count > 1) - { - httpContext.Response.ContentType = "application/json"; - httpContext.Response.StatusCode = (int) HttpStatusCode.InternalServerError; - - var responseContent = JsonConvert.SerializeObject(new - { - errorMessage = "The call is ambiguous and matches multiple workflows.", - workflows = pendingWorkflows - }); - - await httpContext.Response.WriteAsync(responseContent, cancellationToken); + if (await HandleMultipleWorkflowsFoundAsync(httpContext, pendingWorkflows, cancellationToken)) return; - } var pendingWorkflow = pendingWorkflows.Single(); var pendingWorkflowInstance = await workflowInstanceStore.FindByIdAsync(pendingWorkflow.WorkflowInstanceId, cancellationToken); + if (pendingWorkflowInstance is null) { await _next(httpContext); @@ -116,6 +83,19 @@ namespace Elsa.Activities.Http.Middleware } var workflowBlueprintWrapper = await workflowBlueprintReflector.ReflectAsync(httpContext.RequestServices, workflowBlueprint, cancellationToken); + var orderedContentParsers = contentParsers.OrderByDescending(x => x.Priority).ToList(); + var simpleContentType = request.ContentType?.Split(';').First(); + var contentParser = orderedContentParsers.FirstOrDefault(x => x.SupportedContentTypes.Contains(simpleContentType, StringComparer.OrdinalIgnoreCase)) ?? orderedContentParsers.LastOrDefault() ?? new DefaultHttpRequestBodyParser(); + var activityWrapper = workflowBlueprintWrapper.GetUnfilteredActivity(pendingWorkflow.ActivityId!)!; + + if (!await AuthorizeAsync(httpContext, activityWrapper, workflowBlueprint, pendingWorkflow, authorizationHandler, cancellationToken)) + { + httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + + var readContent = await activityWrapper.EvaluatePropertyValueAsync(x => x.ReadContent, cancellationToken); + var inputModel = new HttpRequestModel( request.Path.ToString(), request.Method, @@ -123,13 +103,6 @@ namespace Elsa.Activities.Http.Middleware request.Headers.ToDictionary(x => x.Key, x => x.Value.ToString()) ); - var orderedContentParsers = contentParsers.OrderByDescending(x => x.Priority).ToList(); - var simpleContentType = request.ContentType?.Split(';').First(); - var contentParser = orderedContentParsers.FirstOrDefault(x => x.SupportedContentTypes.Contains(simpleContentType, StringComparer.OrdinalIgnoreCase)) ?? orderedContentParsers.LastOrDefault() ?? new DefaultHttpRequestBodyParser(); - - var activityWrapper = workflowBlueprintWrapper.GetUnfilteredActivity(pendingWorkflow.ActivityId!); - var readContent = await activityWrapper!.EvaluatePropertyValueAsync(x => x.ReadContent, cancellationToken); - if (readContent) { var targetType = await activityWrapper.EvaluatePropertyValueAsync(x => x.TargetType, cancellationToken); @@ -142,7 +115,7 @@ namespace Elsa.Activities.Http.Middleware await workflowLaunchpad.DispatchPendingWorkflowAsync(pendingWorkflow, new WorkflowInput(inputModel), cancellationToken); httpContext.Response.ContentType = "application/json"; - httpContext.Response.StatusCode = (int) HttpStatusCode.Accepted; + httpContext.Response.StatusCode = (int)HttpStatusCode.Accepted; await httpContext.Response.WriteAsync(JsonConvert.SerializeObject(pendingWorkflows), cancellationToken); } else @@ -155,7 +128,7 @@ namespace Elsa.Activities.Http.Middleware && !httpContext.Response.HasStarted) { httpContext.Response.ContentType = "application/json"; - httpContext.Response.StatusCode = (int) HttpStatusCode.InternalServerError; + httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; var faultedResponse = JsonConvert.SerializeObject(new { @@ -172,5 +145,62 @@ namespace Elsa.Activities.Http.Middleware } } } + + private async Task AuthorizeAsync( + HttpContext httpContext, + IActivityBlueprintWrapper httpEndpoint, + IWorkflowBlueprint workflowBlueprint, + CollectedWorkflow pendingWorkflow, + IHttpEndpointAuthorizationHandler authorizationHandler, + CancellationToken cancellationToken) + { + var authorize = await httpEndpoint.EvaluatePropertyValueAsync(x => x.Authorize, cancellationToken); + + if (!authorize) + return true; + + return await authorizationHandler.AuthorizeAsync(new AuthorizeHttpEndpointContext(httpContext, httpEndpoint, workflowBlueprint, pendingWorkflow.WorkflowInstanceId, cancellationToken)); + } + + private async Task HandleNoWorkflowsFoundAsync(HttpContext httpContext, IList pendingWorkflows, PathString? basePath) + { + if (pendingWorkflows.Any()) + return false; + + // If a base path was configured, we are sure the requester tried to execute a workflow that doesn't exist. + // Therefore, sending a 404 response seems appropriate instead of continuing with any subsequent middlewares. + if (basePath != null) + { + httpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + return true; + } + + // If no base path was configured on the other hand, the request could be targeting anything else and should be handled by subsequent middlewares. + await _next(httpContext); + + return true; + } + + private async Task HandleMultipleWorkflowsFoundAsync(HttpContext httpContext, IList pendingWorkflows, CancellationToken cancellationToken) + { + if (pendingWorkflows.Count <= 1) + return false; + + httpContext.Response.ContentType = "application/json"; + httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + + var responseContent = JsonConvert.SerializeObject(new + { + errorMessage = "The call is ambiguous and matches multiple workflows.", + workflows = pendingWorkflows + }); + + await httpContext.Response.WriteAsync(responseContent, cancellationToken); + return true; + } + + private string? GetPath(PathString? basePath, HttpContext httpContext) => basePath != null + ? httpContext.Request.Path.StartsWithSegments(basePath.Value, out _, out var remainingPath) ? remainingPath.Value.ToLowerInvariant() : null + : httpContext.Request.Path.Value.ToLowerInvariant(); } } \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Http/Models/AuthorizeHttpEndpointContext.cs b/src/activities/Elsa.Activities.Http/Models/AuthorizeHttpEndpointContext.cs new file mode 100644 index 000000000..892273056 --- /dev/null +++ b/src/activities/Elsa.Activities.Http/Models/AuthorizeHttpEndpointContext.cs @@ -0,0 +1,8 @@ +using System.Threading; +using Elsa.Services.Models; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Activities.Http.Models +{ + public record AuthorizeHttpEndpointContext(HttpContext HttpContext, IActivityBlueprintWrapper HttpEndpointActivity, IWorkflowBlueprint WorkflowBlueprint, string WorkflowInstanceId, CancellationToken CancellationToken); +} \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Http/Models/HttpWorkflowResource.cs b/src/activities/Elsa.Activities.Http/Models/HttpWorkflowResource.cs new file mode 100644 index 000000000..c32bd11d0 --- /dev/null +++ b/src/activities/Elsa.Activities.Http/Models/HttpWorkflowResource.cs @@ -0,0 +1,6 @@ +using Elsa.Services.Models; + +namespace Elsa.Activities.Http.Models +{ + public record HttpWorkflowResource(IWorkflowBlueprint WorkflowBlueprint, IActivityBlueprint ActivityBlueprint, string WorkflowInstance); +} \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Http/Options/HttpActivityOptions.cs b/src/activities/Elsa.Activities.Http/Options/HttpActivityOptions.cs index 0a2df30c3..635faffe5 100644 --- a/src/activities/Elsa.Activities.Http/Options/HttpActivityOptions.cs +++ b/src/activities/Elsa.Activities.Http/Options/HttpActivityOptions.cs @@ -1,5 +1,7 @@ using System; +using Elsa.Activities.Http.Services; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.Activities.Http.Options { @@ -9,10 +11,12 @@ namespace Elsa.Activities.Http.Options /// The base URL of the server. This should be set to the same value at which the Elsa Server is publicly available. It will be used when generating absolute URLs need to be generated by activities such as SendEmail. /// public Uri BaseUrl { get; set; } = default!; - + /// /// The root path at which HTTP activities can be invoked. /// public PathString? BasePath { get; set; } + + public Func HttpEndpointAuthorizationHandlerFactory { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance; } } \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Http/Services/AuthenticationBasedHttpEndpointAuthorizationHandler.cs b/src/activities/Elsa.Activities.Http/Services/AuthenticationBasedHttpEndpointAuthorizationHandler.cs new file mode 100644 index 000000000..a218a4677 --- /dev/null +++ b/src/activities/Elsa.Activities.Http/Services/AuthenticationBasedHttpEndpointAuthorizationHandler.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Elsa.Activities.Http.Models; +using Microsoft.AspNetCore.Authorization; + +namespace Elsa.Activities.Http.Services +{ + public class AuthenticationBasedHttpEndpointAuthorizationHandler : IHttpEndpointAuthorizationHandler + { + private readonly IAuthorizationService _authorizationService; + public AuthenticationBasedHttpEndpointAuthorizationHandler(IAuthorizationService authorizationService) => _authorizationService = authorizationService; + + public async ValueTask AuthorizeAsync(AuthorizeHttpEndpointContext context) + { + var httpContext = context.HttpContext; + var user = httpContext.User; + + if (!user.Identity.IsAuthenticated) + return false; + + var cancellationToken = context.CancellationToken; + var httpEndpoint = context.HttpEndpointActivity; + var policyName = await httpEndpoint.EvaluatePropertyValueAsync(x => x.Policy, cancellationToken); + + if (string.IsNullOrWhiteSpace(policyName)) + return user.Identity.IsAuthenticated; + + var resource = new HttpWorkflowResource(context.WorkflowBlueprint, httpEndpoint.ActivityBlueprint, context.WorkflowInstanceId); + var authorizationResult = await _authorizationService.AuthorizeAsync(user, resource, policyName); + return authorizationResult.Succeeded; + } + } +} \ No newline at end of file diff --git a/src/activities/Elsa.Activities.Http/Services/IHttpEndpointAuthorizationHandler.cs b/src/activities/Elsa.Activities.Http/Services/IHttpEndpointAuthorizationHandler.cs new file mode 100644 index 000000000..e26d70ef3 --- /dev/null +++ b/src/activities/Elsa.Activities.Http/Services/IHttpEndpointAuthorizationHandler.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Elsa.Activities.Http.Models; + +namespace Elsa.Activities.Http.Services +{ + public interface IHttpEndpointAuthorizationHandler + { + ValueTask AuthorizeAsync(AuthorizeHttpEndpointContext context); + } +} \ No newline at end of file diff --git a/src/designer/elsa-workflows-studio/src/index.html b/src/designer/elsa-workflows-studio/src/index.html index 6af44c4b0..fdfb19d28 100644 --- a/src/designer/elsa-workflows-studio/src/index.html +++ b/src/designer/elsa-workflows-studio/src/index.html @@ -1,231 +1,231 @@ - - - Elsa Dashboard - - - - - - - + + + Elsa Dashboard + + + + + + + - - + + - - - + + + diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Elsa.Samples.HttpEndpointSecurity.csproj b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Elsa.Samples.HttpEndpointSecurity.csproj new file mode 100644 index 000000000..a6f07b1f7 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Elsa.Samples.HttpEndpointSecurity.csproj @@ -0,0 +1,17 @@ + + + + net5.0 + + + + + + + + + + + + + diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Endpoints/Tokens/Create.cs b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Endpoints/Tokens/Create.cs new file mode 100644 index 000000000..62f299d46 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Endpoints/Tokens/Create.cs @@ -0,0 +1,20 @@ +using Elsa.Samples.HttpEndpointSecurity.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Elsa.Samples.HttpEndpointSecurity.Endpoints.Tokens +{ + [ApiController] + [Route("api/tokens")] + public class Create : Controller + { + private readonly ITokenService _tokenService; + public Create(ITokenService tokenService) => _tokenService = tokenService; + + [HttpPost] + public IActionResult Handle(CreateTokenRequestModel model) + { + var token = _tokenService.CreateToken(model.UserName, model.HasMagic); + return Ok(token); + } + } +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Endpoints/Tokens/Models.cs b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Endpoints/Tokens/Models.cs new file mode 100644 index 000000000..b3e3c472a --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Endpoints/Tokens/Models.cs @@ -0,0 +1,4 @@ +namespace Elsa.Samples.HttpEndpointSecurity.Endpoints.Tokens +{ + public record CreateTokenRequestModel(string UserName, bool HasMagic); +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Options/JwtOptions.cs b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Options/JwtOptions.cs new file mode 100644 index 000000000..c522cae6c --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Options/JwtOptions.cs @@ -0,0 +1,9 @@ +namespace Elsa.Samples.HttpEndpointSecurity.Options +{ + public class JwtOptions + { + public string SecretKey { get; set; } + public string Issuer { get; set; } + public string Audience { get; set; } + } +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Program.cs b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Program.cs new file mode 100644 index 000000000..0202bf955 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Program.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace Elsa.Samples.HttpEndpointSecurity +{ + public class Program + { + public static void Main(string[] args) => CreateHostBuilder(args).Build().Run(); + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Properties/launchSettings.json b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Properties/launchSettings.json new file mode 100644 index 000000000..d27f6d0e3 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:18601", + "sslPort": 44326 + } + }, + "profiles": { + "Elsa.Samples.HttpEndpointSecurity": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Services/ITokenService.cs b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Services/ITokenService.cs new file mode 100644 index 000000000..205ffe3cb --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Services/ITokenService.cs @@ -0,0 +1,8 @@ +namespace Elsa.Samples.HttpEndpointSecurity.Services +{ + public interface ITokenService + { + string CreateToken(string userName, bool hasMagic); + bool ValidateToken(string token); + } +} diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Services/TokenService.cs b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Services/TokenService.cs new file mode 100644 index 000000000..54e5bf20e --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Services/TokenService.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Elsa.Samples.HttpEndpointSecurity.Options; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Elsa.Samples.HttpEndpointSecurity.Services +{ + public class TokenService : ITokenService + { + private readonly JwtOptions _options; + + public TokenService(IOptions options) + { + _options = options.Value; + } + + public string CreateToken(string userName, bool hasMagic) + { + var claims = new List() + { + new(JwtRegisteredClaimNames.Sub, userName), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + if (hasMagic) + claims.Add(new Claim("has-magic", "true")); + + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + var tokenDescriptor = new JwtSecurityToken(_options.Issuer, _options.Audience, claims, expires: DateTime.Now.AddYears(1), signingCredentials: credentials); + return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor); + } + + public bool ValidateToken(string token) + { + var mySecret = Encoding.UTF8.GetBytes(_options.SecretKey); + var mySecurityKey = new SymmetricSecurityKey(mySecret); + + var tokenHandler = new JwtSecurityTokenHandler(); + try + { + tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidIssuer = _options.Issuer, + ValidAudience = _options.Audience, + IssuerSigningKey = mySecurityKey, + }, out SecurityToken validatedToken); + } + catch + { + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Startup.cs b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Startup.cs new file mode 100644 index 000000000..eb9a1a4b1 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Startup.cs @@ -0,0 +1,82 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using Elsa.Samples.HttpEndpointSecurity.Options; +using Elsa.Samples.HttpEndpointSecurity.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace Elsa.Samples.HttpEndpointSecurity +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; set; } + + public void ConfigureServices(IServiceCollection services) + { + // Controllers. + services.AddControllers(); + + // Authentication & Authorization. + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + + services + .AddAuthentication(auth => + { + auth.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = Configuration["Jwt:Issuer"], + ValidAudience = Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:SecretKey"])), + NameClaimType = JwtRegisteredClaimNames.Sub, + }; + }); + + // Add a custom policy. + services + .AddAuthorization(auth => auth + .AddPolicy("HasMagic", policy => policy + .RequireClaim("has-magic", "true"))); + + services.Configure(options => Configuration.GetSection("Jwt").Bind(options)); + + // Elsa. + services + .AddElsa(elsa => elsa + .AddHttpActivities(http => Configuration.GetSection("Elsa:Server").Bind(http)) + .AddWorkflowsFrom() + ); + + // Application Services. + services.AddSingleton(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app + .UseRouting() + .UseAuthentication() + .UseAuthorization() + .UseHttpActivities() + .UseEndpoints(endpoints => endpoints.MapControllers()); + } + } +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Workflows/SecureHelloWorkflow.cs b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Workflows/SecureHelloWorkflow.cs new file mode 100644 index 000000000..2becf7618 --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/Workflows/SecureHelloWorkflow.cs @@ -0,0 +1,27 @@ +using System.Net; +using Elsa.Activities.Http; +using Elsa.Builders; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Samples.HttpEndpointSecurity.Workflows +{ + public class SecureHelloWorkflow : IWorkflow + { + public void Build(IWorkflowBuilder builder) + { + builder + .HttpEndpoint(setup => setup + .WithPath("/safe-hello") + .WithMethod("GET") + .WithAuthorize() + .WithPolicy("HasMagic")) + .WriteHttpResponse(setup => setup.WithStatusCode(HttpStatusCode.OK) + .WithContent(context => + { + var httpContext = context.GetService().HttpContext!; + var user = httpContext.User; + return $"Hello {user.Identity!.Name}!"; + })); + } + } +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/appsettings.json b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/appsettings.json new file mode 100644 index 000000000..d4ab83f6f --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Jwt": { + "SecretKey": "This is where you should specify your secret key. A secret key is used to sign and verify Jwt tokens.", + "Issuer": "your-identity-server", + "Audience": "your-api-server" + }, + "AllowedHosts": "*", + "Elsa": { + "Server": { + "BaseUrl": "https://localhost:5001", + "BasePath": "/workflows" + } + } +} diff --git a/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/http-requests.http b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/http-requests.http new file mode 100644 index 000000000..b33f9ff9a --- /dev/null +++ b/src/samples/aspnet/Elsa.Samples.HttpEndpointSecurity/http-requests.http @@ -0,0 +1,31 @@ +POST https://localhost:5001/api/tokens +Content-Type: application/json + +{ + "userName": "Jason", + "hasMagic": false +} + +### + +POST https://localhost:5001/api/tokens +Content-Type: application/json + +{ + "userName": "Janet", + "hasMagic": true +} + +### + +# Jason does not have magic. +GET https://localhost:5001/workflows/safe-hello +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJKYXNvbiIsImp0aSI6IjU3ZDUzOGE4LWQwNmYtNDg2Zi05MzAzLTMyODIxMmZlOWI4MCIsImV4cCI6MTY2MDM5NDg3NSwiaXNzIjoieW91ci1pZGVudGl0eS1zZXJ2ZXIiLCJhdWQiOiJ5b3VyLWFwaS1zZXJ2ZXIifQ.-6mjl-AMXLVSKrM7ofySNbGdf7YgvUmWILloAXB56q8 + +### + +# Janet does have magic. +GET https://localhost:5001/workflows/safe-hello +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJKYW5ldCIsImp0aSI6IjNhMmNlZDg5LWQzYWMtNGQ5NC04YzQzLTU2OWZhZmE1YjYwMCIsImhhcy1tYWdpYyI6InRydWUiLCJleHAiOjE2NjAzOTUyNTksImlzcyI6InlvdXItaWRlbnRpdHktc2VydmVyIiwiYXVkIjoieW91ci1hcGktc2VydmVyIn0.bdBXGdqmiWr5mLcgAvjiKVNxcBgr5w63TJ0OLwZ3kf4 + +###