Implement SAS tokens (#4476)

Issue #4475
This commit is contained in:
Sipke Schoorstra 2023-09-25 00:38:06 +02:00 committed by GitHub
parent 8ff7834ef8
commit 2f69176463
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 406 additions and 9 deletions

View file

@ -245,6 +245,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "drop-ins", "drop-ins", "{6A
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleDropIn", "src\samples\drop-ins\SampleDropIn\SampleDropIn.csproj", "{E0567846-AC0E-4E09-96A1-EFED4B570A01}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.SasTokens", "src\modules\Elsa.SasTokens\Elsa.SasTokens.csproj", "{3095C95A-F1F9-487A-A983-1B100A33E4C7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -611,6 +613,10 @@ Global
{E0567846-AC0E-4E09-96A1-EFED4B570A01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E0567846-AC0E-4E09-96A1-EFED4B570A01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E0567846-AC0E-4E09-96A1-EFED4B570A01}.Release|Any CPU.Build.0 = Release|Any CPU
{3095C95A-F1F9-487A-A983-1B100A33E4C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3095C95A-F1F9-487A-A983-1B100A33E4C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3095C95A-F1F9-487A-A983-1B100A33E4C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3095C95A-F1F9-487A-A983-1B100A33E4C7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{155227F0-A33B-40AA-A4B4-06F813EB921B} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F}
@ -721,5 +727,6 @@ Global
{4E7F15D1-729E-4DA7-992A-C3A2C6054B37} = {C6658DE0-2B2F-47F0-BB61-2CA66D435C09}
{6AB6F2F7-CAC1-45C8-9D87-EEE56BC68601} = {155227F0-A33B-40AA-A4B4-06F813EB921B}
{E0567846-AC0E-4E09-96A1-EFED4B570A01} = {6AB6F2F7-CAC1-45C8-9D87-EEE56BC68601}
{3095C95A-F1F9-487A-A983-1B100A33E4C7} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
EndGlobalSection
EndGlobal

View file

@ -9,12 +9,15 @@ using Elsa.EntityFrameworkCore.Modules.Management;
using Elsa.EntityFrameworkCore.Modules.Runtime;
using Elsa.Extensions;
using Elsa.Http.Handlers;
using Elsa.Http.Options;
using Elsa.MongoDb.Extensions;
using Elsa.MongoDb.Modules.Identity;
using Elsa.MongoDb.Modules.Management;
using Elsa.MongoDb.Modules.Runtime;
using Elsa.WorkflowServer.Web;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
using Proto.Persistence.Sqlite;
using Proto.Persistence.SqlServer;
@ -141,7 +144,12 @@ services
.UseRealTimeWorkflows()
.UseJavaScript(js => js.JintOptions = options => options.AllowClrAccess = true)
.UseLiquid(liquid => liquid.FluidOptions = options => options.Encoder = HtmlEncoder.Default)
.UseHttp(http => http.HttpEndpointAuthorizationHandler = sp => sp.GetRequiredService<AllowAnonymousHttpEndpointAuthorizationHandler>())
.UseHttp(http =>
{
http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options);
http.HttpEndpointAuthorizationHandler = sp => sp.GetRequiredService<AllowAnonymousHttpEndpointAuthorizationHandler>();
})
.UseSasTokens(sas => sas.DataProtectionProvider = sp => DataProtectionProvider.Create("Elsa Workflows"))
.UseEmail(email => email.ConfigureOptions = options => configuration.GetSection("Smtp").Bind(options));
// Initialize drop-ins.
@ -173,7 +181,8 @@ app.UseAuthentication();
app.UseAuthorization();
// Elsa API endpoints for designer.
app.UseWorkflowsApi();
var routePrefix = app.Services.GetRequiredService<IOptions<HttpActivityOptions>>().Value.ApiRoutePrefix;
app.UseWorkflowsApi(routePrefix);
// Captures unhandled exceptions and returns a JSON response.
app.UseJsonSerializationErrorHandler();

View file

@ -23,6 +23,17 @@
"Port": 2525,
"DefaultSender": "noreply@crmservices.com"
},
"Http": {
"BaseUrl": "https://localhost:5001",
"BasePath": "/workflows",
"ApiRoutePrefix": "elsa/api",
"AvailableContentTypes": [
"application/json",
"application/xml",
"text/plain",
"text/html"
]
},
"Identity": {
"Tokens": {
"SigningKey": "secret-signing-key",

View file

@ -2,6 +2,17 @@ using FastEndpoints;
namespace Elsa.Abstractions;
public abstract class ElsaEndpointWithoutRequest : EndpointWithoutRequest
{
protected void ConfigurePermissions(params string[] permissions)
{
if (!EndpointSecurityOptions.SecurityIsEnabled)
AllowAnonymous();
else
Permissions(new[] { PermissionNames.All }.Concat(permissions).ToArray());
}
}
public abstract class ElsaEndpointWithoutRequest<TResponse> : EndpointWithoutRequest<TResponse> where TResponse : notnull
{
protected void ConfigurePermissions(params string[] permissions)

View file

@ -24,6 +24,7 @@
<ItemGroup>
<ProjectReference Include="..\Elsa.Liquid\Elsa.Liquid.csproj"/>
<ProjectReference Include="..\Elsa.SasTokens\Elsa.SasTokens.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj"/>
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj"/>
<ProjectReference Include="..\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj"/>

View file

@ -0,0 +1,51 @@
using Elsa.Expressions.Models;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
using Elsa.Http.Options;
using Elsa.SasTokens.Contracts;
using Microsoft.Extensions.Options;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
internal static class ExpressionExecutionContextExtensions
{
public static string GenerateEventTriggerUrl(this ExpressionExecutionContext context, string eventName, TimeSpan lifetime)
{
var token = context.GenerateEventTriggerTokenInternal(eventName, lifetime);
return context.GenerateEventTriggerUrlInternal(token);
}
public static string GenerateEventTriggerUrl(this ExpressionExecutionContext context, string eventName, DateTimeOffset expiresAt)
{
var token = context.GenerateEventTriggerTokenInternal(eventName, expiresAt: expiresAt);
return context.GenerateEventTriggerUrlInternal(token);
}
public static string GenerateEventTriggerUrl(this ExpressionExecutionContext context, string eventName)
{
var token = context.GenerateEventTriggerTokenInternal(eventName);
return context.GenerateEventTriggerUrlInternal(token);
}
private static string GenerateEventTriggerUrlInternal(this ExpressionExecutionContext context, string token)
{
var options = context.GetRequiredService<IOptions<HttpActivityOptions>>().Value;
var url = $"{options.ApiRoutePrefix}/events/trigger?t={token}";
var absoluteUrlProvider = context.GetRequiredService<IAbsoluteUrlProvider>();
return absoluteUrlProvider.ToAbsoluteUrl(url).ToString();
}
private static string GenerateEventTriggerTokenInternal(this ExpressionExecutionContext context, string eventName, TimeSpan? lifetime = default, DateTimeOffset? expiresAt = default)
{
var workflowInstanceId = context.GetWorkflowExecutionContext().Id;
var payload = new EventTokenPayload(eventName, workflowInstanceId);
var tokenService = context.GetRequiredService<ITokenService>();
return lifetime != null
? tokenService.CreateToken(payload, lifetime.Value)
: expiresAt != null
? tokenService.CreateToken(payload, expiresAt.Value)
: tokenService.CreateToken(payload);
}
}

View file

@ -1,11 +1,13 @@
using Elsa.Http.Contracts;
using Elsa.Http.Models;
using Elsa.SasTokens.Contracts;
using Elsa.Workflows.Core;
using Elsa.Workflows.Core.Models;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
internal static class ActivityContextExtensions
internal static class HttpActivityExecutionContextExtensions
{
public static async Task<object?> ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, CancellationToken cancellationToken)
{

View file

@ -0,0 +1,8 @@
namespace Elsa.Http.Models;
/// <summary>
/// Represents the payload of an event, serialized as a secured token.
/// </summary>
/// <param name="EventName">The name of the event.</param>
/// <param name="WorkflowInstanceId">The ID of the workflow instance to trigger with the event.</param>
public record EventTokenPayload(string EventName, string WorkflowInstanceId);

View file

@ -17,10 +17,15 @@ public class HttpActivityOptions
/// </summary>
public Uri BaseUrl { get; set; } = default!;
/// <summary>
/// The prefix used for API routes.
/// </summary>
public string ApiRoutePrefix { get; set; } = "elsa/api";
/// <summary>
/// A configurable set of available content types available from the <see cref="WriteHttpResponse"/> activity.
/// </summary>
public ISet<string> AvailableContentTypes { get; set; } = new HashSet<string>
public ISet<string> AvailableContentTypes { get; set; } = new SortedSet<string>
{
"application/json",
"application/xml",

View file

@ -1,6 +1,7 @@
using Elsa.Extensions;
using Elsa.Http.Models;
using Elsa.JavaScript.Notifications;
using Elsa.JavaScript.TypeDefinitions.Builders;
using Elsa.JavaScript.TypeDefinitions.Contracts;
using Elsa.JavaScript.TypeDefinitions.Models;
using Elsa.Mediator.Contracts;
@ -12,7 +13,7 @@ namespace Elsa.Http.Scripting.JavaScript;
/// Configures the JavaScript engine with additional .NET types that can be instantiated.
/// </summary>
[PublicAPI]
public class HttpJavaScriptHandler : INotificationHandler<EvaluatingJavaScript>, ITypeDefinitionProvider
public class HttpJavaScriptHandler : INotificationHandler<EvaluatingJavaScript>, ITypeDefinitionProvider, IFunctionDefinitionProvider
{
private readonly ITypeDescriber _typeDescriber;
@ -23,26 +24,59 @@ public class HttpJavaScriptHandler : INotificationHandler<EvaluatingJavaScript>,
{
_typeDescriber = typeDescriber;
}
/// <inheritdoc />
public Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken)
Task INotificationHandler<EvaluatingJavaScript>.HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken)
{
var engine = notification.Engine;
engine.RegisterType<HttpRequestHeaders>();
engine.RegisterType<Downloadable>();
var activityExecutionContext = notification.Context;
engine.SetValue("createEventTriggerUrl", (Func<string, object?, string>)((eventName, lifetimeOrExpiryDate) =>
{
return lifetimeOrExpiryDate switch
{
TimeSpan lifetime => activityExecutionContext.GenerateEventTriggerUrl(eventName, lifetime),
DateTimeOffset expiryDate => activityExecutionContext.GenerateEventTriggerUrl(eventName, expiryDate),
_ => activityExecutionContext.GenerateEventTriggerUrl(eventName)
};
}));
return Task.CompletedTask;
}
/// <inheritdoc />
public ValueTask<IEnumerable<TypeDefinition>> GetTypeDefinitionsAsync(TypeDefinitionContext context)
ValueTask<IEnumerable<TypeDefinition>> ITypeDefinitionProvider.GetTypeDefinitionsAsync(TypeDefinitionContext context)
{
var definitions = GetTypeDefinitions(context);
return new(definitions);
}
/// <inheritdoc />
ValueTask<IEnumerable<FunctionDefinition>> IFunctionDefinitionProvider.GetFunctionDefinitionsAsync(TypeDefinitionContext context)
{
var definitions = GetFunctionDefinitions(context);
return new(definitions);
}
private IEnumerable<TypeDefinition> GetTypeDefinitions(TypeDefinitionContext context)
{
yield return _typeDescriber.DescribeType(typeof(HttpRequestHeaders));
yield return _typeDescriber.DescribeType(typeof(Downloadable));
}
private IEnumerable<FunctionDefinition> GetFunctionDefinitions(TypeDefinitionContext context)
{
yield return CreateFunctionDefinition(function => function.Name("createEventTriggerUrl").ReturnType("string").Parameter("eventName", "string"));
yield return CreateFunctionDefinition(function => function.Name("createEventTriggerUrl").ReturnType("string").Parameter("eventName", "string").Parameter("lifetime", "TimeSpan"));
yield return CreateFunctionDefinition(function => function.Name("createEventTriggerUrl").ReturnType("string").Parameter("eventName", "string").Parameter("expiresAt", "DateTimeOffset"));
}
private FunctionDefinition CreateFunctionDefinition(Action<FunctionDefinitionBuilder> setup)
{
var builder = new FunctionDefinitionBuilder();
setup(builder);
return builder.BuildFunctionDefinition();
}
}

View file

@ -4,11 +4,17 @@ using Microsoft.Extensions.Options;
namespace Elsa.Http.Services;
/// <inheritdoc />
public class DefaultAbsoluteUrlProvider : IAbsoluteUrlProvider
{
private readonly IOptions<HttpActivityOptions> _options;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultAbsoluteUrlProvider"/> class.
/// </summary>
public DefaultAbsoluteUrlProvider(IOptions<HttpActivityOptions> options) => _options = options;
/// <inheritdoc />
public Uri ToAbsoluteUrl(string relativePath)
{
var baseUrl = _options.Value.BaseUrl;

View file

@ -0,0 +1,66 @@
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
namespace Elsa.SasTokens.Contracts;
/// <summary>
/// A service that can create and decrypt SAS (Shared Access Signature) tokens using the <see cref="Microsoft.AspNetCore.DataProtection.IDataProtector"/> service.
/// </summary>
public class DataProtectorTokenService : ITokenService
{
private readonly IDataProtector _dataProtector;
/// <summary>
/// Initializes a new instance of the <see cref="DataProtectorTokenService"/> class.
/// </summary>
public DataProtectorTokenService(IDataProtectionProvider dataProtector)
{
_dataProtector = dataProtector.CreateProtector("Elsa Tokens");
}
/// <inheritdoc />
public string CreateToken<T>(T payload, TimeSpan lifetime)
{
var json = JsonSerializer.Serialize(payload);
return _dataProtector.ToTimeLimitedDataProtector().Protect(json, lifetime);
}
/// <inheritdoc />
public string CreateToken<T>(T payload, DateTimeOffset expiresAt)
{
var json = JsonSerializer.Serialize(payload);
return _dataProtector.ToTimeLimitedDataProtector().Protect(json, expiresAt);
}
/// <inheritdoc />
public string CreateToken<T>(T payload)
{
var json = JsonSerializer.Serialize(payload);
return _dataProtector.Protect(json);
}
/// <inheritdoc />
public bool TryDecryptToken<T>(string token, out T payload)
{
payload = default!;
try
{
payload = DecryptToken<T>(token);
return true;
}
catch
{
// ignored.
}
return false;
}
/// <inheritdoc />
public T DecryptToken<T>(string token)
{
var json = _dataProtector.Unprotect(token);
return JsonSerializer.Deserialize<T>(json)!;
}
}

View file

@ -0,0 +1,32 @@
namespace Elsa.SasTokens.Contracts;
/// <summary>
/// A service that can create and decrypt SAS (Shared Access Signature) tokens.
/// </summary>
public interface ITokenService
{
/// <summary>
/// Creates a SAS (Shared Access Signature) token containing the specified data.
/// </summary>
string CreateToken<T>(T payload, TimeSpan lifetime);
/// <summary>
/// Creates a SAS (Shared Access Signature) token containing the specified data.
/// </summary>
string CreateToken<T>(T payload, DateTimeOffset expiresAt);
/// <summary>
/// Creates a SAS (Shared Access Signature) token containing the specified data.
/// </summary>
string CreateToken<T>(T payload);
/// <summary>
/// Decrypts the specified SAS token.
/// </summary>
T DecryptToken<T>(string token);
/// <summary>
/// Decrypts the specified SAS token.
/// </summary>
bool TryDecryptToken<T>(string token, out T payload);
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\common.props"/>
<Import Project="..\..\..\configureawait.props"/>
<PropertyGroup>
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
<Description>
Provides services to geenrate SAS tokens.
</Description>
<PackageTags>elsa module security sas tokens</PackageTags>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net6.0' Or '$(TargetFramework)' == 'net7.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Features\Elsa.Features.csproj"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,20 @@
using Elsa.Features.Services;
using Elsa.SasTokens.Features;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides extensions to install the <see cref="SasTokens"/> feature.
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Install the <see cref="SasTokens"/> feature.
/// </summary>
public static IModule UseSasTokens(this IModule module, Action<SasTokensFeature>? configure = default)
{
module.Configure(configure);
return module;
}
}

View file

@ -0,0 +1,24 @@
using Elsa.SasTokens.Contracts;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.SasTokens.Extensions;
/// <summary>
/// Contains extension methods for the <see cref="IServiceCollection"/> interface.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds the SAS tokens module to the service collection.
/// </summary>
public static IServiceCollection AddSasTokens(this IServiceCollection services, Func<IServiceProvider, IDataProtectionProvider> dataProtectionProvider)
{
services.AddSingleton<ITokenService>(sp =>
{
var protectionProvider = dataProtectionProvider(sp);
return new DataProtectorTokenService(protectionProvider);
});
return services;
}
}

View file

@ -0,0 +1,28 @@
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Elsa.SasTokens.Extensions;
using Microsoft.AspNetCore.DataProtection;
namespace Elsa.SasTokens.Features;
/// <summary>
/// Adds the SAS tokens feature to the workflow runtime.
/// </summary>
public class SasTokensFeature : FeatureBase
{
/// <inheritdoc />
public SasTokensFeature(IModule module) : base(module)
{
}
/// <summary>
/// Gets or sets the data protection provider used to create the <see cref="Microsoft.AspNetCore.DataProtection.IDataProtector"/> used to encrypt and decrypt the SAS tokens.
/// </summary>
public Func<IServiceProvider, IDataProtectionProvider> DataProtectionProvider { get; set; } = _ => Microsoft.AspNetCore.DataProtection.DataProtectionProvider.Create("Elsa Workflows");
/// <inheritdoc />
public override void Apply()
{
Services.AddSasTokens(DataProtectionProvider);
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -1,10 +1,11 @@
using Elsa.Abstractions;
using Elsa.Http.Contracts;
using Elsa.Workflows.Api.Endpoints.Events.Trigger;
using Elsa.Workflows.Core.Models;
using Elsa.Workflows.Runtime.Contracts;
using JetBrains.Annotations;
namespace Elsa.Workflows.Api.Endpoints.Events.Trigger;
namespace Elsa.Workflows.Api.Endpoints.Events.TriggerAuthenticated;
/// <summary>
/// Triggers all workflows that are waiting for the specified event.

View file

@ -0,0 +1,56 @@
using Elsa.Abstractions;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
using Elsa.SasTokens.Contracts;
using Elsa.Workflows.Runtime.Contracts;
using JetBrains.Annotations;
namespace Elsa.Workflows.Api.Endpoints.Events.TriggerPublic;
/// <summary>
/// Resumes a workflow instance blocked by a specified event encoded in the provided SAS token.
/// </summary>
[PublicAPI]
internal class Trigger : ElsaEndpointWithoutRequest
{
private readonly ITokenService _tokenService;
private readonly IEventPublisher _eventPublisher;
private readonly IHttpBookmarkProcessor _httpBookmarkProcessor;
/// <inheritdoc />
public Trigger(ITokenService tokenService, IEventPublisher eventPublisher, IHttpBookmarkProcessor httpBookmarkProcessor)
{
_tokenService = tokenService;
_eventPublisher = eventPublisher;
_httpBookmarkProcessor = httpBookmarkProcessor;
}
/// <inheritdoc />
public override void Configure()
{
Get("/events/trigger");
AllowAnonymous();
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var token = Query<string>("t")!;
if (!_tokenService.TryDecryptToken<EventTokenPayload>(token, out var payload))
{
AddError("Invalid token.");
await SendErrorsAsync(cancellation: cancellationToken);
return;
}
var eventName = payload.EventName;
var workflowInstanceId = payload.WorkflowInstanceId;
var results = await _eventPublisher.PublishAsync(eventName, workflowInstanceId: workflowInstanceId, cancellationToken: cancellationToken);
await _httpBookmarkProcessor.ProcessBookmarks(results, cancellationToken: cancellationToken);
if (!HttpContext.Response.HasStarted)
await SendOkAsync(cancellationToken);
}
}