Http endpoint security (#1389)

This commit is contained in:
Sipke Schoorstra 2021-08-13 16:17:57 +02:00 committed by GitHub
parent f192870ce8
commit a3631d6d26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 698 additions and 241 deletions

View file

@ -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}

View file

@ -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; }

View file

@ -45,5 +45,17 @@ namespace Elsa.Activities.Http
public static ISetupActivity<HttpEndpoint> WithTargetType(this ISetupActivity<HttpEndpoint> activity, Func<Type?> value) => activity.Set(x => x.TargetType, value).WithReadContent();
public static ISetupActivity<HttpEndpoint> WithTargetType(this ISetupActivity<HttpEndpoint> activity, Type? value) => activity.Set(x => x.TargetType, value).WithReadContent();
public static ISetupActivity<HttpEndpoint> WithTargetType<T>(this ISetupActivity<HttpEndpoint> activity) => activity.Set(x => x.TargetType, typeof(T)).WithReadContent();
public static ISetupActivity<HttpEndpoint> WithAuthorize(this ISetupActivity<HttpEndpoint> activity, Func<ActivityExecutionContext, ValueTask<bool>> value) => activity.Set(x => x.Authorize, value);
public static ISetupActivity<HttpEndpoint> WithAuthorize(this ISetupActivity<HttpEndpoint> activity, Func<ValueTask<bool>> value) => activity.Set(x => x.Authorize, value);
public static ISetupActivity<HttpEndpoint> WithAuthorize(this ISetupActivity<HttpEndpoint> activity, Func<ActivityExecutionContext, bool> value) => activity.Set(x => x.Authorize, value);
public static ISetupActivity<HttpEndpoint> WithAuthorize(this ISetupActivity<HttpEndpoint> activity, Func<bool> value) => activity.Set(x => x.Authorize, value);
public static ISetupActivity<HttpEndpoint> WithAuthorize(this ISetupActivity<HttpEndpoint> activity, bool value = true) => activity.Set(x => x.Authorize, value);
public static ISetupActivity<HttpEndpoint> WithPolicy(this ISetupActivity<HttpEndpoint> activity, Func<ActivityExecutionContext, ValueTask<string?>> value) => activity.Set(x => x.Policy, value);
public static ISetupActivity<HttpEndpoint> WithPolicy(this ISetupActivity<HttpEndpoint> activity, Func<ValueTask<string?>> value) => activity.Set(x => x.Policy, value);
public static ISetupActivity<HttpEndpoint> WithPolicy(this ISetupActivity<HttpEndpoint> activity, Func<ActivityExecutionContext, string?> value) => activity.Set(x => x.Policy, value);
public static ISetupActivity<HttpEndpoint> WithPolicy(this ISetupActivity<HttpEndpoint> activity, Func<string?> value) => activity.Set(x => x.Policy, value);
public static ISetupActivity<HttpEndpoint> WithPolicy(this ISetupActivity<HttpEndpoint> activity, string? value) => activity.Set(x => x.Policy, value);
}
}

View file

@ -23,6 +23,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.9" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="5.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />

View file

@ -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;

View file

@ -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<IHttpContextAccessor, HttpContextAccessor>();
services.AddHttpClient(nameof(SendHttpRequest));
services.AddAuthorizationCore();
services
.AddSingleton<IHttpRequestBodyParser, DefaultHttpRequestBodyParser>()
@ -43,6 +45,7 @@ namespace Microsoft.Extensions.DependencyInjection
.AddSingleton<IHttpResponseContentReader, FileResponseContentReader>()
.AddSingleton<IActionContextAccessor, ActionContextAccessor>()
.AddSingleton<IAbsoluteUrlProvider, DefaultAbsoluteUrlProvider>()
.AddSingleton(sp => sp.GetRequiredService<IOptions<HttpActivityOptions>>().Value.HttpEndpointAuthorizationHandlerFactory(sp))
.AddBookmarkProvider<HttpEndpointBookmarkProvider>()
.AddHttpContextAccessor()
.AddNotificationHandlers(typeof(ConfigureJavaScriptEngine))

View file

@ -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<IHttpRequestBodyParser> 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<HttpEndpoint>(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<HttpEndpoint>(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<bool> AuthorizeAsync(
HttpContext httpContext,
IActivityBlueprintWrapper<HttpEndpoint> 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<bool> HandleNoWorkflowsFoundAsync(HttpContext httpContext, IList<CollectedWorkflow> 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<bool> HandleMultipleWorkflowsFoundAsync(HttpContext httpContext, IList<CollectedWorkflow> 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();
}
}

View file

@ -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<HttpEndpoint> HttpEndpointActivity, IWorkflowBlueprint WorkflowBlueprint, string WorkflowInstanceId, CancellationToken CancellationToken);
}

View file

@ -0,0 +1,6 @@
using Elsa.Services.Models;
namespace Elsa.Activities.Http.Models
{
public record HttpWorkflowResource(IWorkflowBlueprint WorkflowBlueprint, IActivityBlueprint ActivityBlueprint, string WorkflowInstance);
}

View file

@ -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.
/// </summary>
public Uri BaseUrl { get; set; } = default!;
/// <summary>
/// The root path at which HTTP activities can be invoked.
/// </summary>
public PathString? BasePath { get; set; }
public Func<IServiceProvider, IHttpEndpointAuthorizationHandler> HttpEndpointAuthorizationHandlerFactory { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance<AuthenticationBasedHttpEndpointAuthorizationHandler>;
}
}

View file

@ -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<bool> 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;
}
}
}

View file

@ -0,0 +1,10 @@
using System.Threading.Tasks;
using Elsa.Activities.Http.Models;
namespace Elsa.Activities.Http.Services
{
public interface IHttpEndpointAuthorizationHandler
{
ValueTask<bool> AuthorizeAsync(AuthorizeHttpEndpointContext context);
}
}

View file

@ -1,231 +1,231 @@
<!DOCTYPE html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0"/>
<title>Elsa Dashboard</title>
<link rel="icon" type="image/png" sizes="32x32" href="/build/assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/build/assets/images/favicon-16x16.png">
<link rel="stylesheet" href="/build/assets/fonts/inter/inter.css">
<link href="/build/elsa-workflows-studio.css" rel="stylesheet">
<script src="/build/assets/js/monaco-editor/min/vs/loader.js"></script>
<script type="module" src="/build/elsa-workflows-studio.esm.js"></script>
<script nomodule src="/build/elsa-workflows-studio.js"></script>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0"/>
<title>Elsa Dashboard</title>
<link rel="icon" type="image/png" sizes="32x32" href="/build/assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/build/assets/images/favicon-16x16.png">
<link rel="stylesheet" href="/build/assets/fonts/inter/inter.css">
<link href="/build/elsa-workflows-studio.css" rel="stylesheet">
<script src="/build/assets/js/monaco-editor/min/vs/loader.js"></script>
<script type="module" src="/build/elsa-workflows-studio.esm.js"></script>
<script nomodule src="/build/elsa-workflows-studio.js"></script>
</head>
<body>
<elsa-studio-root server-url="https://localhost:11000" monaco-lib-path="build/assets/js/monaco-editor/min" culture="en-US">
<!-- The root dashboard component -->
<elsa-studio-dashboard></elsa-studio-dashboard>
<!-- The root dashboard component -->
<elsa-studio-dashboard></elsa-studio-dashboard>
<!-- Instead of using the full dashboard component, you can display lower-level components instead-->
<!--<elsa-workflow-instance-list-screen></elsa-workflow-instance-list-screen>-->
<!--<elsa-workflow-definition-editor-screen></elsa-workflow-definition-editor-screen>-->
<!-- Instead of using the full dashboard component, you can display lower-level components instead-->
<!--<elsa-workflow-instance-list-screen></elsa-workflow-instance-list-screen>-->
<!--<elsa-workflow-definition-editor-screen></elsa-workflow-definition-editor-screen>-->
</elsa-studio-root>
<!-- Keep this to avoid Stencil from stripping classes added by dagre-d3 <!-->
<div class="node add label-container hidden"></div>
<script type="module">
// Integration demos.
// Integration demos.
// Import publicly exposed services and models.
import {confirmDialogService, EventTypes} from "/build/index.esm.js";
// Import publicly exposed services and models.
import {confirmDialogService, EventTypes} from "/build/index.esm.js";
// Custom plugin that changes the icon of the ReadLine activity.
function CustomReadLinePlugin(elsaStudio) {
// Replace icon used for 'ReadLine' activity.
elsaStudio.activityIconProvider.register(
'ReadLine',
`<span class="elsa-rounded-lg elsa-inline-flex elsa-p-3 elsa-bg-blue-50 elsa-text-blue-700 elsa-ring-4 elsa-ring-white">
<svg class="elsa-h-6 elsa-w-6" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
</svg>
</span>`);
}
// Custom plugin that changes the icon of the ReadLine activity.
function CustomReadLinePlugin(elsaStudio) {
// Replace icon used for 'ReadLine' activity.
elsaStudio.activityIconProvider.register(
'ReadLine',
`<span class="elsa-rounded-lg elsa-inline-flex elsa-p-3 elsa-bg-blue-50 elsa-text-blue-700 elsa-ring-4 elsa-ring-white">
<svg class="elsa-h-6 elsa-w-6" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
</svg>
</span>`);
}
// Custom plugin that adds a menu item to the bulk actions list on the workflow instance list view.
function CustomBulkActionsPlugin(elsaStudio) {
const eventBus = elsaStudio.eventBus;
// Custom plugin that adds a menu item to the bulk actions list on the workflow instance list view.
function CustomBulkActionsPlugin(elsaStudio) {
const eventBus = elsaStudio.eventBus;
eventBus.on(EventTypes.WorkflowInstanceBulkActionsLoading, async e => {
e.bulkActions.push({
name: 'Reset', text: 'Reset', handler: async () => {
if (!await confirmDialogService.show("Reset Workflows", "Are you sure you want to do this??"))
return;
eventBus.on(EventTypes.WorkflowInstanceBulkActionsLoading, async e => {
e.bulkActions.push({
name: 'Reset', text: 'Reset', handler: async () => {
if (!await confirmDialogService.show("Reset Workflows", "Are you sure you want to do this??"))
return;
alert('Resetting workflows!');
}
});
})
}
// Custom plugin that intercepts outgoing HTTP requests and their responses.
function CustomHttpMiddlewarePlugin(elsaStudio) {
const eventBus = elsaStudio.eventBus;
eventBus.on('http-client-created', e => {
// Register a sample middleware.
e.service.register({
onRequest(request) {
console.log('onRequest');
return request;
},
onResponse(response) {
console.log('onResponse');
return response;
}
});
});
eventBus.on('workflow-imported', e => {
console.log('workflow-imported');
});
}
// Simple plugin that listens for the 'workflow-model-changed' event.
function WorkflowModelChangedListenerPlugin(elsaStudio) {
const {eventBus} = elsaStudio;
eventBus.on('workflow-model-changed', e => {
console.log(`Workflow model changed. New model: ${e}`)
});
}
// Plugin that completely customizes the activity editor for the WriteLine activity.
function CustomActivityEditorPlugin(elsaStudio) {
const {eventBus, getOrCreateProperty, htmlToElement} = elsaStudio;
// When the activity editor is about to be rendered.
eventBus.on('activity-editor-displaying', e => {
const {activityDescriptor, activityModel} = e;
// We only handle WriteLine activities.
if(activityDescriptor.type !== 'WriteLine')
return;
let tabs = [...e.tabs];
// Remove the 'Properties' tab:
tabs = tabs.filter(x => x.tabName !== 'Properties');
// Create a custom tab.
const customTab = {
tabName: 'Custom Tab',
renderContent: () => this.renderWriteLineEditor(activityDescriptor, activityModel)
}
// Add the custom tab.
tabs = [customTab, ...tabs];
// Update the tabs array.
e.tabs = tabs;
});
this.renderWriteLineEditor = (activityDescriptor, activityModel) => {
const propertyEditor = document.createElement('elsa-property-editor');
const propertyDescriptor = activityDescriptor.inputProperties.find(x => x.name === 'Text');
const propertyModel = getOrCreateProperty(activityModel, propertyDescriptor.name);
const defaultSyntax = propertyDescriptor.defaultSyntax || 'Literal';
const currentValue = propertyModel.expressions[defaultSyntax] || '';
const editorHtml = `<textarea class="focus:elsa-ring-blue-500 focus:elsa-border-blue-500 elsa-block elsa-w-full elsa-min-w-0 elsa-rounded-md sm:elsa-text-sm elsa-border-gray-300" rows="5">${currentValue}</textarea>`;
const editorElement = htmlToElement(editorHtml);
propertyEditor.append(editorElement);
propertyEditor.propertyDescriptor = propertyDescriptor;
propertyEditor.propertyModel = propertyModel;
editorElement.addEventListener('change', (e) => {
const input = e.currentTarget;
propertyModel.expressions[defaultSyntax] = input.value;
});
return propertyEditor;
alert('Resetting workflows!');
}
});
})
}
// Custom plugin that intercepts outgoing HTTP requests and their responses.
function CustomHttpMiddlewarePlugin(elsaStudio) {
const eventBus = elsaStudio.eventBus;
eventBus.on('http-client-created', e => {
// Register a sample middleware.
e.service.register({
onRequest(request) {
console.log('onRequest');
return request;
},
onResponse(response) {
console.log('onResponse');
return response;
}
});
});
eventBus.on('workflow-imported', e => {
console.log('workflow-imported');
});
}
// Simple plugin that listens for the 'workflow-model-changed' event.
function WorkflowModelChangedListenerPlugin(elsaStudio) {
const {eventBus} = elsaStudio;
eventBus.on('workflow-model-changed', e => {
console.log(`Workflow model changed. New model: ${e}`)
});
}
// Plugin that completely customizes the activity editor for the WriteLine activity.
function CustomActivityEditorPlugin(elsaStudio) {
const {eventBus, getOrCreateProperty, htmlToElement} = elsaStudio;
// When the activity editor is about to be rendered.
eventBus.on('activity-editor-displaying', e => {
const {activityDescriptor, activityModel} = e;
// We only handle WriteLine activities.
if (activityDescriptor.type !== 'WriteLine')
return;
let tabs = [...e.tabs];
// Remove the 'Properties' tab:
tabs = tabs.filter(x => x.tabName !== 'Properties');
// Create a custom tab.
const customTab = {
tabName: 'Custom Tab',
renderContent: () => this.renderWriteLineEditor(activityDescriptor, activityModel)
}
// Add the custom tab.
tabs = [customTab, ...tabs];
// Update the tabs array.
e.tabs = tabs;
});
this.renderWriteLineEditor = (activityDescriptor, activityModel) => {
const propertyEditor = document.createElement('elsa-property-editor');
const propertyDescriptor = activityDescriptor.inputProperties.find(x => x.name === 'Text');
const propertyModel = getOrCreateProperty(activityModel, propertyDescriptor.name);
const defaultSyntax = propertyDescriptor.defaultSyntax || 'Literal';
const currentValue = propertyModel.expressions[defaultSyntax] || '';
const editorHtml = `<textarea class="focus:elsa-ring-blue-500 focus:elsa-border-blue-500 elsa-block elsa-w-full elsa-min-w-0 elsa-rounded-md sm:elsa-text-sm elsa-border-gray-300" rows="5">${currentValue}</textarea>`;
const editorElement = htmlToElement(editorHtml);
propertyEditor.append(editorElement);
propertyEditor.propertyDescriptor = propertyDescriptor;
propertyEditor.propertyModel = propertyModel;
editorElement.addEventListener('change', (e) => {
const input = e.currentTarget;
propertyModel.expressions[defaultSyntax] = input.value;
});
return propertyEditor;
}
}
// Custom activity property type input control plugin:
function CustomPropertyFieldPlugin(elsaStudio) {
const {propertyDisplayManager} = elsaStudio;
// Custom activity property type input control plugin:
function CustomPropertyFieldPlugin(elsaStudio) {
const {propertyDisplayManager} = elsaStudio;
// Register custom driver.
propertyDisplayManager.addDriver('my-custom-property-type', () => new CustomPropertyFieldDriver(elsaStudio));
}
// Register custom driver.
propertyDisplayManager.addDriver('my-custom-property-type', () => new CustomPropertyFieldDriver(elsaStudio));
}
// Custom activity property type input control driver:
function CustomPropertyFieldDriver(elsaStudio) {
// Custom activity property type input control driver:
function CustomPropertyFieldDriver(elsaStudio) {
// Get convenience methods.
const {getOrCreateProperty, htmlToElement} = elsaStudio;
// Get convenience methods.
const {getOrCreateProperty, htmlToElement} = elsaStudio;
this.display = (activity, propertyDescriptor) => {
this.display = (activity, propertyDescriptor) => {
// Get the property model.
const propertyModel = getOrCreateProperty(activity, propertyDescriptor.name);
// Get the property model.
const propertyModel = getOrCreateProperty(activity, propertyDescriptor.name);
// Get the configured default syntax name.
const defaultSyntax = propertyDescriptor.defaultSyntax || 'Literal';
// Get the configured default syntax name.
const defaultSyntax = propertyDescriptor.defaultSyntax || 'Literal';
// Get the current property value for the default syntax.
const currentValue = propertyModel.expressions[defaultSyntax] || '';
// Get the current property value for the default syntax.
const currentValue = propertyModel.expressions[defaultSyntax] || '';
// Create a property editor element (for displaying label, hint and syntax toggle).
// This will wrap our custom control.
const propertyEditor = document.createElement('elsa-property-editor');
// Create a property editor element (for displaying label, hint and syntax toggle).
// This will wrap our custom control.
const propertyEditor = document.createElement('elsa-property-editor');
// Our custom input element control. Can be anything you want.
// Using HTML string to easily construct an actual element object.
// Better yet would be to implement a component with Stencil, Angular or React if you;re using any of these frameworks.
const inputHtml =
`<input type="text"
// Our custom input element control. Can be anything you want.
// Using HTML string to easily construct an actual element object.
// Better yet would be to implement a component with Stencil, Angular or React if you;re using any of these frameworks.
const inputHtml =
`<input type="text"
class="disabled:elsa-opacity-50 disabled:elsa-cursor-not-allowed focus:elsa-ring-blue-500 focus:elsa-border-blue-500 elsa-block elsa-w-full elsa-min-w-0 elsa-rounded-md sm:elsa-text-sm elsa-border-gray-300"
value="${currentValue}"
/>`;
// Create an actual input element from the HTML string.
const inputElement = htmlToElement(inputHtml);
// Create an actual input element from the HTML string.
const inputElement = htmlToElement(inputHtml);
// Add the custom input control element to the property editor as a child.
propertyEditor.append(inputElement);
// Add the custom input control element to the property editor as a child.
propertyEditor.append(inputElement);
// Initialize the property editor.
propertyEditor.propertyDescriptor = propertyDescriptor;
propertyEditor.propertyModel = propertyModel;
// Initialize the property editor.
propertyEditor.propertyDescriptor = propertyDescriptor;
propertyEditor.propertyModel = propertyModel;
// Setup change handler for custom control that updates the property model.
inputElement.addEventListener('change', (e) => {
const input = e.currentTarget;
propertyModel.expressions[defaultSyntax] = input.value;
});
// Setup change handler for custom control that updates the property model.
inputElement.addEventListener('change', (e) => {
const input = e.currentTarget;
propertyModel.expressions[defaultSyntax] = input.value;
});
// return the created custom control.
return propertyEditor;
};
}
// return the created custom control.
return propertyEditor;
};
}
// Get a handle to the elsa-studio-root element.
const elsaStudioRoot = document.querySelector('elsa-studio-root');
// Get a handle to the elsa-studio-root element.
const elsaStudioRoot = document.querySelector('elsa-studio-root');
// Configure Elsa.
// (async () => {
// // Wait until the component is available.
// await customElements.whenDefined('elsa-studio-root');
//
// // Install plugins.
// await elsaStudioRoot.addPlugins([CustomReadLinePlugin, CustomBulkActionsPlugin, CustomHttpMiddlewarePlugin, CustomPropertyFieldPlugin]);
// })();
// Configure Elsa.
// (async () => {
// // Wait until the component is available.
// await customElements.whenDefined('elsa-studio-root');
//
// // Install plugins.
// await elsaStudioRoot.addPlugins([CustomReadLinePlugin, CustomBulkActionsPlugin, CustomHttpMiddlewarePlugin, CustomPropertyFieldPlugin]);
// })();
// Alternatively, configure Elsa during the 'initializing' event.
elsaStudioRoot.addEventListener('initializing', e => {
const elsa = e.detail;
elsa.pluginManager.registerPlugins([CustomReadLinePlugin, CustomBulkActionsPlugin, CustomHttpMiddlewarePlugin, CustomPropertyFieldPlugin, WorkflowModelChangedListenerPlugin, CustomActivityEditorPlugin]);
});
// Alternatively, configure Elsa during the 'initializing' event.
elsaStudioRoot.addEventListener('initializing', e => {
const elsa = e.detail;
elsa.pluginManager.registerPlugins([CustomReadLinePlugin, CustomBulkActionsPlugin, CustomHttpMiddlewarePlugin, CustomPropertyFieldPlugin, WorkflowModelChangedListenerPlugin, CustomActivityEditorPlugin]);
});
// Some components publish DOM events that we can handle directly:
elsaStudioRoot.addEventListener('workflow-changed', e => {
console.log('Workflow model changed! New model: ${e.detail}');
})
// Some components publish DOM events that we can handle directly:
elsaStudioRoot.addEventListener('workflow-changed', e => {
console.log('Workflow model changed! New model: ${e.detail}');
})
</script>
</body>

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.9" />
<PackageReference Include="System.Text.Encodings.Web" Version="5.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\activities\Elsa.Activities.Http\Elsa.Activities.Http.csproj" />
<ProjectReference Include="..\..\..\core\Elsa\Elsa.csproj" />
</ItemGroup>
</Project>

View file

@ -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);
}
}
}

View file

@ -0,0 +1,4 @@
namespace Elsa.Samples.HttpEndpointSecurity.Endpoints.Tokens
{
public record CreateTokenRequestModel(string UserName, bool HasMagic);
}

View file

@ -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; }
}
}

View file

@ -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<Startup>();
});
}
}

View file

@ -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"
}
}
}
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Samples.HttpEndpointSecurity.Services
{
public interface ITokenService
{
string CreateToken(string userName, bool hasMagic);
bool ValidateToken(string token);
}
}

View file

@ -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<JwtOptions> options)
{
_options = options.Value;
}
public string CreateToken(string userName, bool hasMagic)
{
var claims = new List<Claim>()
{
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;
}
}
}

View file

@ -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<JwtOptions>(options => Configuration.GetSection("Jwt").Bind(options));
// Elsa.
services
.AddElsa(elsa => elsa
.AddHttpActivities(http => Configuration.GetSection("Elsa:Server").Bind(http))
.AddWorkflowsFrom<Startup>()
);
// Application Services.
services.AddSingleton<ITokenService, TokenService>();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseHttpActivities()
.UseEndpoints(endpoints => endpoints.MapControllers());
}
}
}

View file

@ -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<IHttpContextAccessor>().HttpContext!;
var user = httpContext.User;
return $"Hello {user.Identity!.Name}!";
}));
}
}
}

View file

@ -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"
}
}
}

View file

@ -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
###