diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2e0a96d..d044763 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -2,7 +2,8 @@ name: CI on: push: - branches: [master] + branches: + - "*" pull_request: branches: [master] @@ -20,6 +21,10 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: 5.0.x + - name: Setup .NET 6 + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "6.0.100-rc.2.21458.9" - name: Restore dependencies run: dotnet restore source - name: Build diff --git a/.github/workflows/PreRelease.yml b/.github/workflows/PreRelease.yml index db37146..78cc01e 100644 --- a/.github/workflows/PreRelease.yml +++ b/.github/workflows/PreRelease.yml @@ -24,6 +24,11 @@ jobs: uses: gittools/actions/gitversion/setup@v0.9.7 with: versionSpec: "5.x" + - name: Setup .NET 6 + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "6.0.100-rc.2.21458.9" + include-prerelease: true - name: Determine Version id: gitversion uses: gittools/actions/gitversion/execute@v0.9.7 diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index d52be8f..a1803f2 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -24,6 +24,10 @@ jobs: uses: gittools/actions/gitversion/setup@v0.9.7 with: versionSpec: "5.x" + - name: Setup .NET 6 + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "6.0.100-rc.2.21458.9" - name: Determine Version id: gitversion uses: gittools/actions/gitversion/execute@v0.9.7 diff --git a/Samples/HelloWorld/EventHandlers/AppHomeOpenedHandler.cs b/Samples/HelloWorld/EventHandlers/AppHomeOpenedHandler.cs deleted file mode 100644 index 9b8fea7..0000000 --- a/Samples/HelloWorld/EventHandlers/AppHomeOpenedHandler.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Slackbot.Net.Endpoints.Abstractions; -using Slackbot.Net.Endpoints.Models.Events; - -namespace HelloWorld.EventHandlers -{ - public class AppHomeOpenedHandler : IHandleAppHomeOpened - { - public Task Handle(EventMetaData eventMetadata, AppHomeOpenedEvent payload) - { - string json = JsonConvert.SerializeObject(payload); - Console.WriteLine(json); - return Task.FromResult(new EventHandledResponse(json)); - } - } -} \ No newline at end of file diff --git a/Samples/HelloWorld/EventHandlers/AppHomeViewSubmissionHandler.cs b/Samples/HelloWorld/EventHandlers/AppHomeViewSubmissionHandler.cs deleted file mode 100644 index 4384514..0000000 --- a/Samples/HelloWorld/EventHandlers/AppHomeViewSubmissionHandler.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Threading.Tasks; -using Slackbot.Net.Endpoints.Abstractions; -using Slackbot.Net.Endpoints.Models.Interactive.ViewSubmissions; - -namespace HelloWorld.EventHandlers -{ - public class AppHomeViewSubmissionHandler : IHandleViewSubmissions - { - public Task Handle(ViewSubmission payload) - { - return Task.FromResult(new EventHandledResponse("YoLO!")); - } - } -} \ No newline at end of file diff --git a/Samples/HelloWorld/EventHandlers/HelloWorldHandler.cs b/Samples/HelloWorld/EventHandlers/HelloWorldHandler.cs deleted file mode 100644 index 2a1aea2..0000000 --- a/Samples/HelloWorld/EventHandlers/HelloWorldHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Threading.Tasks; -using Slackbot.Net.Endpoints.Abstractions; -using Slackbot.Net.Endpoints.Models.Events; - -namespace HelloWorld.EventHandlers -{ - internal class HelloWorldHandler : IHandleAppMentions - { - public Task Handle(EventMetaData eventMetadata, AppMentionEvent message) - { - Console.WriteLine($"Hello world, {message.User}\n"); - return Task.FromResult(new EventHandledResponse("Responded")); - } - - public bool ShouldHandle(AppMentionEvent appMention) => appMention.Text.Contains("hw"); - - public (string HandlerTrigger, string Description) GetHelpDescription() => ("hw", "Returns a hello world message"); - } -} \ No newline at end of file diff --git a/Samples/HelloWorld/EventHandlers/HiddenTestHandler.cs b/Samples/HelloWorld/EventHandlers/HiddenTestHandler.cs deleted file mode 100644 index e5d786c..0000000 --- a/Samples/HelloWorld/EventHandlers/HiddenTestHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Slackbot.Net.Endpoints.Abstractions; -using Slackbot.Net.Endpoints.Models.Events; - -namespace HelloWorld.EventHandlers -{ - public class HiddenTestHandler : IHandleAppMentions - { - public Task Handle(EventMetaData eventMetadata, AppMentionEvent slackEvent) - { - Console.WriteLine("Doing stuff from OtherAppMentionHandler: " + JsonConvert.SerializeObject(slackEvent)); - return Task.FromResult(new EventHandledResponse("Wrote stuff to log")); - } - - public bool ShouldHandle(AppMentionEvent appMentionEvent) => appMentionEvent.Text == "test"; - - } -} \ No newline at end of file diff --git a/Samples/HelloWorld/EventHandlers/ListPublicCommands.cs b/Samples/HelloWorld/EventHandlers/ListPublicCommands.cs deleted file mode 100644 index ce6ca6c..0000000 --- a/Samples/HelloWorld/EventHandlers/ListPublicCommands.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Slackbot.Net.Endpoints.Abstractions; -using Slackbot.Net.Endpoints.Models.Events; - -namespace HelloWorld.EventHandlers -{ - public class ListPublicCommands : IShortcutAppMentions - { - private readonly IEnumerable _handlers; - - - public ListPublicCommands(IEnumerable allHandlers) - { - _handlers = allHandlers; - } - - public Task Handle(EventMetaData eventMetadata, AppMentionEvent @event) - { - var publicHandlersToBeListedBack = _handlers.Where(handler => handler.GetHelpDescription().HandlerTrigger != string.Empty) - - .Aggregate("*Public trigger mentions*", (current, kvPair) => current + $"\n• `{kvPair.GetType()}. `{kvPair.GetHelpDescription().HandlerTrigger}` : _{kvPair.GetHelpDescription().Description}_"); - - - Console.WriteLine(publicHandlersToBeListedBack); - - - var ssh = _handlers.Where(handler => handler.GetHelpDescription().HandlerTrigger == string.Empty) - .Aggregate("*Private trigger handlers*", (current, kvPair) => current + $"\n• `{kvPair.GetType()}`"); - Console.WriteLine(ssh); - - return Task.CompletedTask; - } - - public bool ShouldShortcut(AppMentionEvent appMention) => appMention.Text.Contains("help"); - } -} \ No newline at end of file diff --git a/Samples/HelloWorld/EventHandlers/MemberJoinedChannelHandler.cs b/Samples/HelloWorld/EventHandlers/MemberJoinedChannelHandler.cs deleted file mode 100644 index 54001fc..0000000 --- a/Samples/HelloWorld/EventHandlers/MemberJoinedChannelHandler.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Slackbot.Net.Endpoints.Abstractions; -using Slackbot.Net.Endpoints.Models.Events; - -namespace HelloWorld.EventHandlers -{ - public class MemberJoinedChannelHandler : IHandleMemberJoinedChannel - { - public Task Handle(EventMetaData eventMetadata, MemberJoinedChannelEvent slackEvent) - { - Console.WriteLine("Doing stuff from MemberJoinedChannelHandler: " + JsonConvert.SerializeObject(slackEvent)); - return Task.FromResult(new EventHandledResponse("Wrote stuff to log")); - } - } -} \ No newline at end of file diff --git a/Samples/HelloWorld/EventHandlers/PublicJokeHandler.cs b/Samples/HelloWorld/EventHandlers/PublicJokeHandler.cs deleted file mode 100644 index 9de9939..0000000 --- a/Samples/HelloWorld/EventHandlers/PublicJokeHandler.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Slackbot.Net.Endpoints.Abstractions; -using Slackbot.Net.Endpoints.Models.Events; - -namespace HelloWorld.EventHandlers -{ - public class PublicJokeHandler : IHandleAppMentions - { - public Task Handle(EventMetaData eventMetadata, AppMentionEvent slackEvent) - { - Console.WriteLine("Doing stuff from AppmentionHandler: " + JsonConvert.SerializeObject(slackEvent)); - return Task.FromResult(new EventHandledResponse("Wrote stuff to log")); - } - - public bool ShouldHandle(AppMentionEvent slackEvent) => slackEvent.Text == "test"; - - public (string HandlerTrigger, string Description) GetHelpDescription() => ("telljoke", "bot tells a joke"); - } -} \ No newline at end of file diff --git a/Samples/HelloWorld/HelloWorld.csproj b/Samples/HelloWorld/HelloWorld.csproj index de80d6b..830fb4e 100644 --- a/Samples/HelloWorld/HelloWorld.csproj +++ b/Samples/HelloWorld/HelloWorld.csproj @@ -1,8 +1,10 @@ - net5.0 + net6.0 false + enable + preview diff --git a/Samples/HelloWorld/MyTokenStore.cs b/Samples/HelloWorld/MyTokenStore.cs deleted file mode 100644 index 89cd3ff..0000000 --- a/Samples/HelloWorld/MyTokenStore.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Slackbot.Net.Abstractions.Hosting; - -namespace HelloWorld -{ - public class MyTokenStore : ITokenStore - { - private readonly List _workspaces; - public string SlackToken = Environment.GetEnvironmentVariable("Slackbot_SlackApiKey_BotUser"); - - public MyTokenStore() - { - _workspaces = new List() - { - new Workspace { - Token = SlackToken, - TeamId = "T0EC3DG3A" - } - }; - } - - public Task> GetTokens() - { - return Task.FromResult(_workspaces.Select(c => c.Token)); - } - - public Task GetTokenByTeamId(string teamId) - { - return Task.FromResult(_workspaces.First(t => t.TeamId == teamId).Token); - } - - public Task Delete(string token) - { - var workspaceToRemove = _workspaces.First(w => w.Token == token); - _workspaces.Remove(workspaceToRemove); - return Task.CompletedTask; - } - - private class Workspace - { - public string Token { get; set; } - public string TeamId { get; set; } - } - } -} \ No newline at end of file diff --git a/Samples/HelloWorld/Program.cs b/Samples/HelloWorld/Program.cs index 5a9952a..d7340c7 100644 --- a/Samples/HelloWorld/Program.cs +++ b/Samples/HelloWorld/Program.cs @@ -1,35 +1,41 @@ -using HelloWorld.EventHandlers; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; using Slackbot.Net.Endpoints.Hosting; -using Slackbot.Net.SlackClients.Http.Extensions; +using Slackbot.Net.Endpoints.Authentication; +using Slackbot.Net.Endpoints.Abstractions; +using Slackbot.Net.Endpoints.Models.Events; +using Slackbot.Net.Abstractions.Hosting; -namespace HelloWorld +var builder = WebApplication.CreateBuilder(args); + +// Needed in production to verify that incoming event payloads are from Slack +builder.Services.AddAuthentication() + .AddSlackbotEvents(c => c.SigningSecret = Environment.GetEnvironmentVariable("CLIENT_SIGNING_SECRET")); + +// Setup event handlers +builder.Services.AddSlackBotEvents() + .AddAppMentionHandler(); + + +var app = builder.Build(); +app.UseSlackbot(enableAuth:!app.Environment.IsDevelopment()); // disable during development for easier testing +app.Run(); + +class DoStuff : IHandleAppMentions { - public class Program + public bool ShouldHandle(AppMentionEvent slackEvent) => slackEvent.Text.Contains("dostuff"); + + public Task Handle(EventMetaData eventMetadata, AppMentionEvent slackEvent) { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.ConfigureServices(services => - { - services.AddSlackClientBuilder(); - services.AddSlackBotEvents() - .AddAppMentionHandler() - .AddAppMentionHandler() - .AddAppMentionHandler() - .AddMemberJoinedChannelHandler() - .AddShortcut() - .AddViewSubmissionHandler() - .AddAppHomeOpenedHandler(); - }); - webBuilder.Configure(app => app.UseSlackbot()); - }); + // handler code executed given ShouldHandle returns true + Console.WriteLine("Doing stuff!"); + return Task.FromResult(new EventHandledResponse("yolo")); } -} \ No newline at end of file +} + +class TokenStore : ITokenStore +{ + public string SlackToken = Environment.GetEnvironmentVariable("SLACK_TOKEN"); + + public Task> GetTokens() => Task.FromResult(new [] { SlackToken }.AsEnumerable()); + public Task GetTokenByTeamId(string teamId) => Task.FromResult(SlackToken); + public Task Delete(string token) => throw new NotImplementedException("Single workspace app"); +} diff --git a/Samples/HelloWorld/test.http b/Samples/HelloWorld/test.http index 4142799..54c5478 100644 --- a/Samples/HelloWorld/test.http +++ b/Samples/HelloWorld/test.http @@ -1,12 +1,14 @@ -# Mimicks a payload slack would send for app_mention events, like "@yourbot hw" in this case: -POST http://localhost:1337/events +# Mimicks a payload slack would send for app_mention events, like "@yourbot dostuff" in this case: +GET http://localhost:5000/ +X-Slack-Request-Timestamp: 12331231 +X-Slack-Signature: v0:abc123etcetc { "team_id": "T0EC3DG3A", "event": { "type": "app_mention", "user": "USRAR1YTV", - "text" : "<@BOT123> hw", + "text" : "<@BOT123> dostuff", "channel": "C92QZTVEF" } -} \ No newline at end of file +} diff --git a/readme.md b/readme.md index c4e47ea..02f687d 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ # Slackbot.NET -[![Build](https://github.com/slackbot-net/slackbot.net/workflows/CI/badge.svg)](https://github.com/slackbot.net/slackbot.net/actions) +[![Build](https://github.com/slackbot-net/slackbot.net/workflows/CI/badge.svg)](https://github.com/slackbot.net/slackbot.net/actions) ## What? @@ -16,24 +16,68 @@ Download it from NuGet:[![NuGet](https://img.shields.io/nuget/dt/slackbot.net.en - `challenge` - `app_mention` - `view_submission` -- `member_joined_channel` +- `member_joined_channel` ### Configuration + +A complete ASP.NET Core 6 Slackbot: ```csharp +var builder = WebApplication.CreateBuilder(args); -services.AddSlackBotEvents() - .AddAppMentionHandler(); -``` +// Needed to verify that incoming event payloads are from Slack +builder.Services.AddAuthentication() + .AddSlackbotEvents(c => c.SigningSecret = Environment.GetEnvironmentVariable("secret")); -and +// Setup event handlers +builder.Services.AddSlackBotEvents() + .AddAppMentionHandler() -```csharp - app.UseSlackbot("/events"); + +var app = builder.Build(); +app.UseSlackbot(); //or app.UseSlackbot(enableAuth:false) during development +app.Run(); + +class DoStuff : IHandleAppMentions +{ + public bool ShouldHandle(AppMentionEvent slackEvent) => slackEvent.Text.Contains("CLIENT_SIGNING_SECRET"); + + public Task Handle(EventMetaData eventMetadata, AppMentionEvent slackEvent) + { + Console.WriteLine("Doing stuff!"); + return Task.FromResult(new EventHandledResponse("yolo")); + } +} + +class MyTokenStore : ITokenStore +{ + public string SlackToken = Environment.GetEnvironmentVariable("SLACK_TOKEN"); + + public Task> GetTokens() => Task.FromResult(new[] { SlackToken }); + public Task GetTokenByTeamId(string teamId) => Task.FromResult(SlackToken); + public Task Delete(string token) => throw new NotImplementedException("Single workspace app"); +} ``` +A slack app can be distributed either as a single-workspace application (1 single Slack token), or as a _distributed_ Slack application where other workspaces can install it them self either via a web page, or via the Slack App Store. + ### Single workspace Slack app + + Implement the `ITokenStore`, and return a single workspace token from relevant methods (get/get-all) + + + ### Advanced: Distributed Slack app + + Implement the `ITokenStore` and store/retrieve tokens to any storage. The install and uninstall flows are currently not part of this library, so you would have to implement the relevant redirects triggers and callbacks endpoints yourself. Those callback endpoints needs to save the tokens to the storage used in your `ITokenStore` implementation. + + A typical callback endpoint would consist of: + 1) Receiving the OAuth2 code as query string as part of (the final redirect of the OAuth2 authorization code flow) + 2) On receive, a server-to-server API request to exchange the code for an Slack token for the installing workspace. Via your apps ClientId, ClientSecret and the OAuth2 code, use the Slack OAuthAccessV2 HTTP API here. + 3) On a successful exchange, save the WorkspaceId and the token to your storage. + + Providing an OOB solution for distributed apps is on the roadmap. + ### Samples - Check the [samples](/Samples/). \ No newline at end of file + Check the [samples](/Samples/). diff --git a/source/.editorconfig b/source/.editorconfig index 3298f9b..750fc2f 100644 --- a/source/.editorconfig +++ b/source/.editorconfig @@ -1,6 +1,9 @@ # Remove the line below if you want to inherit .editorconfig settings from higher directories root = true +[*] +charset = utf-8 + # C# files [*.cs] diff --git a/source/Slackbot.Net.sln b/source/Slackbot.Net.sln index 7f58d44..4d72e79 100644 --- a/source/Slackbot.Net.sln +++ b/source/Slackbot.Net.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{53AD07F7-25C8-4F0F-A551-4979D2DD08F3}" EndProject @@ -14,7 +14,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{F22E EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{10CBAF56-F382-480E-B8BE-CBF270617AD8}" ProjectSection(SolutionItems) = preProject - ..\build.cake = ..\build.cake ..\CI.yml = ..\.github\workflows\CI.yml ..\Release.yml = ..\.github\workflows\Release.yml ..\PreRelease.yml = ..\.github\workflows\PreRelease.yml diff --git a/source/src/Slackbot.Net.Endpoints/Abstractions/IHandleAppMentions.cs b/source/src/Slackbot.Net.Endpoints/Abstractions/IHandleAppMentions.cs index bd12ab9..57c5c02 100644 --- a/source/src/Slackbot.Net.Endpoints/Abstractions/IHandleAppMentions.cs +++ b/source/src/Slackbot.Net.Endpoints/Abstractions/IHandleAppMentions.cs @@ -6,7 +6,7 @@ namespace Slackbot.Net.Endpoints.Abstractions public interface IHandleAppMentions { Task Handle(EventMetaData eventMetadata, AppMentionEvent slackEvent); - bool ShouldHandle(AppMentionEvent slackEvent); + bool ShouldHandle(AppMentionEvent slackEvent) => true; (string HandlerTrigger, string Description) GetHelpDescription() => ("", ""); } } \ No newline at end of file diff --git a/source/src/Slackbot.Net.Endpoints/Abstractions/INoOpAppMentions.cs b/source/src/Slackbot.Net.Endpoints/Abstractions/INoOpAppMentions.cs new file mode 100644 index 0000000..d0c2844 --- /dev/null +++ b/source/src/Slackbot.Net.Endpoints/Abstractions/INoOpAppMentions.cs @@ -0,0 +1,7 @@ +namespace Slackbot.Net.Endpoints.Abstractions +{ + public interface INoOpAppMentions : IHandleAppMentions + { + + } +} \ No newline at end of file diff --git a/source/src/Slackbot.Net.Endpoints/AppMentionEventHandlerSelector.cs b/source/src/Slackbot.Net.Endpoints/AppMentionEventHandlerSelector.cs index 15e5485..c5bc05c 100644 --- a/source/src/Slackbot.Net.Endpoints/AppMentionEventHandlerSelector.cs +++ b/source/src/Slackbot.Net.Endpoints/AppMentionEventHandlerSelector.cs @@ -24,6 +24,7 @@ public async Task> GetAppMentionEventHandlerFor( { var allHandlers = _provider.GetServices(); var shortCutter = _provider.GetService(); + var noopHandler = _provider.GetService(); if (shortCutter != null && shortCutter.ShouldShortcut(slackEvent)) { @@ -31,17 +32,23 @@ public async Task> GetAppMentionEventHandlerFor( return new List(); } - return SelectHandler(allHandlers, slackEvent); + return SelectHandler(allHandlers, noopHandler, slackEvent); } - private IEnumerable SelectHandler(IEnumerable handlers, AppMentionEvent message) + private IEnumerable SelectHandler(IEnumerable handlers, INoOpAppMentions noOpAppMentions, AppMentionEvent message) { var matchingHandlers = handlers.Where(s => s.ShouldHandle(message)); if (matchingHandlers.Any()) return matchingHandlers; - - return new List {new NoOpAppMentionEventHandler(_loggerFactory.CreateLogger())}; + + if(noOpAppMentions != null) + return new List { noOpAppMentions }; + + return new List + { + new NoOpAppMentionEventHandler(_loggerFactory.CreateLogger()) + }; } } } \ No newline at end of file diff --git a/source/src/Slackbot.Net.Endpoints/Authentication/AuthenticationBuilderExtensions.cs b/source/src/Slackbot.Net.Endpoints/Authentication/AuthenticationBuilderExtensions.cs new file mode 100644 index 0000000..818f06e --- /dev/null +++ b/source/src/Slackbot.Net.Endpoints/Authentication/AuthenticationBuilderExtensions.cs @@ -0,0 +1,27 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace Slackbot.Net.Endpoints.Authentication +{ + public static class AuthenticationBuilderExtensions + { + public static AuthenticationBuilder AddSlackbotEvents(this AuthenticationBuilder builder, Action optionsAction) + { + builder.Services.Configure(optionsAction); + return builder.AddScheme(SlackbotEventsAuthenticationConstants.AuthenticationScheme, optionsAction); + } + } + + public class SlackbotEventsAuthenticationOptions : AuthenticationSchemeOptions + { + [Required] + public string SigningSecret { get; set; } + } + + public static class SlackbotEventsAuthenticationConstants + { + public const string AuthenticationScheme = "SlacbotEventsAuthenticationScheme"; + } +} diff --git a/source/src/Slackbot.Net.Endpoints/Authentication/SlackbotEventsAuthenticationAuthenticationHandler.cs b/source/src/Slackbot.Net.Endpoints/Authentication/SlackbotEventsAuthenticationAuthenticationHandler.cs new file mode 100644 index 0000000..69340d7 --- /dev/null +++ b/source/src/Slackbot.Net.Endpoints/Authentication/SlackbotEventsAuthenticationAuthenticationHandler.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Slackbot.Net.Endpoints.Authentication +{ + internal class SlackbotEventsAuthenticationAuthenticationHandler : AuthenticationHandler + { + private const string TimestampHeaderName = "X-Slack-Request-Timestamp"; + private const string SignatureHeaderName = "X-Slack-Signature"; + + private readonly string _signingSecret; + + public SlackbotEventsAuthenticationAuthenticationHandler( + IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, + ISystemClock clock) : base(options, logger, encoder, clock) + { + if (string.IsNullOrEmpty(options.CurrentValue.SigningSecret)) + throw new ArgumentNullException(nameof(SlackbotEventsAuthenticationOptions.SigningSecret)); + _signingSecret = options.CurrentValue.SigningSecret; + } + + protected override async Task HandleAuthenticateAsync() + { + IHeaderDictionary headers = Request.Headers; + + string timestamp = headers[TimestampHeaderName].FirstOrDefault(); + string signature = headers[SignatureHeaderName].FirstOrDefault(); + + if (timestamp == null) + { + return HandleRequestResult.Fail($"Missing header {TimestampHeaderName}"); + } + + if (signature == null) + { + return HandleRequestResult.Fail($"Missing header {SignatureHeaderName}"); + } + + bool isNumber = long.TryParse(timestamp, out long timestampAsLong); + + if (!isNumber) + { + return HandleRequestResult.Fail($"Invalid header. Header {TimestampHeaderName} not a number"); + } + + Request.EnableBuffering(); + using var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + string body = await reader.ReadToEndAsync(); + Request.Body.Position = 0; + + if (IsValidSlackSignature(signature, timestampAsLong, body)) + { + return HandleRequestResult.Success(new AuthenticationTicket(new ClaimsPrincipal(), SlackbotEventsAuthenticationConstants.AuthenticationScheme)); + } + + return HandleRequestResult.Fail("Verification of Slack request failed."); + + } + + private static readonly DateTime Seventies = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); + private static long Now => (long)DateTime.UtcNow.Subtract(Seventies).TotalSeconds; + + private bool IsValidSlackSignature(string incomingSignature, long timestamp, string body) + { + if (!IsWithinRange(timestamp, TimeSpan.FromMinutes(5))) + { + return false; + } + + return incomingSignature == GeneratedSignature(timestamp, body); + } + + private static bool IsWithinRange(long timestamp, TimeSpan tolerance) + { + return Now - timestamp <= tolerance.TotalSeconds; + } + + private string GeneratedSignature(long timestamp, string body) + { + string signature = $"v0:{timestamp}:{body}"; + var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(_signingSecret)); + byte[] hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(signature)); + var builder = new StringBuilder("v0="); + foreach (byte part in hash) + { + builder.Append(part.ToString("x2")); + } + + return builder.ToString(); + } + } +} diff --git a/source/src/Slackbot.Net.Endpoints/Hosting/IAppBuilderExtensions.cs b/source/src/Slackbot.Net.Endpoints/Hosting/IAppBuilderExtensions.cs index 0ac5169..52a2f3e 100644 --- a/source/src/Slackbot.Net.Endpoints/Hosting/IAppBuilderExtensions.cs +++ b/source/src/Slackbot.Net.Endpoints/Hosting/IAppBuilderExtensions.cs @@ -1,30 +1,25 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Slackbot.Net.Endpoints.Middlewares; namespace Slackbot.Net.Endpoints.Hosting { public static class IAppBuilderExtensions { - public static IApplicationBuilder UseSlackbot(this IApplicationBuilder app, string path = "/events") + public static IApplicationBuilder UseSlackbot(this IApplicationBuilder app, bool enableAuth = true) { - app.MapWhen(c => IsSlackRequest(c, path), a => - { - a.UseMiddleware(); - a.MapWhen(Challenge.ShouldRun, b => b.UseMiddleware()); - a.MapWhen(Uninstall.ShouldRun, b => b.UseMiddleware()); - a.MapWhen(AppMentionEvents.ShouldRun, b => b.UseMiddleware()); - a.MapWhen(MemberJoinedEvents.ShouldRun, b => b.UseMiddleware()); - a.MapWhen(AppHomeOpenedEvents.ShouldRun, b => b.UseMiddleware()); - a.MapWhen(InteractiveEvents.ShouldRun, b => b.UseMiddleware()); - }); - - return app; - } + if (enableAuth) + app.UseMiddleware(); - private static bool IsSlackRequest(HttpContext ctx, string path) - { - return ctx.Request.Path == path && ctx.Request.Method == "POST"; + app.UseMiddleware(); + app.MapWhen(Challenge.ShouldRun, b => b.UseMiddleware()); + app.MapWhen(Uninstall.ShouldRun, b => b.UseMiddleware()); + app.MapWhen(AppMentionEvents.ShouldRun, b => b.UseMiddleware()); + app.MapWhen(MemberJoinedEvents.ShouldRun, b => b.UseMiddleware()); + app.MapWhen(AppHomeOpenedEvents.ShouldRun, b => b.UseMiddleware()); + app.MapWhen(InteractiveEvents.ShouldRun, b => b.UseMiddleware()); + + return app; } } -} \ No newline at end of file +} + diff --git a/source/src/Slackbot.Net.Endpoints/Hosting/ISlackbotHandlersBuilder.cs b/source/src/Slackbot.Net.Endpoints/Hosting/ISlackbotHandlersBuilder.cs index 503de29..6f8b454 100644 --- a/source/src/Slackbot.Net.Endpoints/Hosting/ISlackbotHandlersBuilder.cs +++ b/source/src/Slackbot.Net.Endpoints/Hosting/ISlackbotHandlersBuilder.cs @@ -9,7 +9,8 @@ public interface ISlackbotHandlersBuilder public ISlackbotHandlersBuilder AddMemberJoinedChannelHandler() where T : class, IHandleMemberJoinedChannel; public ISlackbotHandlersBuilder AddViewSubmissionHandler() where T : class, IHandleViewSubmissions; public ISlackbotHandlersBuilder AddInteractiveBlockActionsHandler() where T : class, IHandleInteractiveBlockActions; - + public ISlackbotHandlersBuilder AddAppHomeOpenedHandler() where T : class, IHandleAppHomeOpened; + public ISlackbotHandlersBuilder AddNoOpAppMentionHandler() where T : class, INoOpAppMentions; } -} \ No newline at end of file +} diff --git a/source/src/Slackbot.Net.Endpoints/Hosting/SlackBotHandlersBuilder.cs b/source/src/Slackbot.Net.Endpoints/Hosting/SlackBotHandlersBuilder.cs index d8c2c29..4c43dd8 100644 --- a/source/src/Slackbot.Net.Endpoints/Hosting/SlackBotHandlersBuilder.cs +++ b/source/src/Slackbot.Net.Endpoints/Hosting/SlackBotHandlersBuilder.cs @@ -47,5 +47,11 @@ public ISlackbotHandlersBuilder AddShortcut() where T : class, IShortcutAppMe _services.AddSingleton(); return this; } + + public ISlackbotHandlersBuilder AddNoOpAppMentionHandler() where T : class, INoOpAppMentions + { + _services.AddSingleton(); + return this; + } } } \ No newline at end of file diff --git a/source/src/Slackbot.Net.Endpoints/Middlewares/HttpItemsManager.cs b/source/src/Slackbot.Net.Endpoints/Middlewares/HttpItemsManager.cs index 3c2fc23..89f0a06 100644 --- a/source/src/Slackbot.Net.Endpoints/Middlewares/HttpItemsManager.cs +++ b/source/src/Slackbot.Net.Endpoints/Middlewares/HttpItemsManager.cs @@ -1,5 +1,4 @@ using System.IO; -using System.Security.Policy; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -34,7 +33,7 @@ public async Task Invoke(HttpContext context) if (body.StartsWith("{")) { var jObject = JObject.Parse(body); - + _logger.LogTrace(body); if (jObject.ContainsKey("challenge")) { context.Items.Add(HttpItemKeys.ChallengeKey, jObject["challenge"]); @@ -63,7 +62,7 @@ public async Task Invoke(HttpContext context) var interactivePayloadTyped = ToInteractiveType(payload, body); context.Items.Add(HttpItemKeys.InteractivePayloadKey, interactivePayloadTyped); } - + context.Request.Body.Position = 0; } @@ -87,7 +86,7 @@ private static SlackEvent ToEventType(JObject eventJson, string raw) return unknownSlackEvent; } } - + private static Interaction ToInteractiveType(JObject payloadJson, string raw) { var eventType = GetEventType(payloadJson); @@ -95,7 +94,7 @@ private static Interaction ToInteractiveType(JObject payloadJson, string raw) { case InteractionTypes.ViewSubmission: var viewSubmission = payloadJson.ToObject(); - + var view = payloadJson["view"] as JObject; var viewState = view["state"] as JObject;; viewSubmission.ViewId = view.Value("id"); @@ -109,14 +108,14 @@ private static Interaction ToInteractiveType(JObject payloadJson, string raw) return unknownSlackEvent; } } - + public static string GetEventType(JObject eventJson) { if (eventJson != null) { return eventJson["type"].Value(); } - + return "unknown"; } } diff --git a/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotEventAuthMiddleware.cs b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotEventAuthMiddleware.cs new file mode 100644 index 0000000..bb539df --- /dev/null +++ b/source/src/Slackbot.Net.Endpoints/Middlewares/SlackbotEventAuthMiddleware.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Slackbot.Net.Endpoints.Authentication; + +namespace Slackbot.Net.Endpoints.Middlewares +{ + internal class SlackbotEventAuthMiddleware + { + private readonly RequestDelegate _next; + + public SlackbotEventAuthMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext ctx, ILogger logger) + { + bool success = false; + try + { + var res = await ctx.AuthenticateAsync(SlackbotEventsAuthenticationConstants.AuthenticationScheme); + success = res.Succeeded; + } + catch (InvalidOperationException ioe) + { + throw new InvalidOperationException("Did you forget to call services.AddAuthentication().AddSlackbotEvents()?", ioe); + } + + if (success) + { + await _next(ctx); + } + else + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + await ctx.Response.WriteAsync("UNAUTHORIZED"); + } + } + } +} diff --git a/source/src/Slackbot.Net.Endpoints/Slackbot.Net.Endpoints.csproj b/source/src/Slackbot.Net.Endpoints/Slackbot.Net.Endpoints.csproj index c2d1c27..5d93435 100644 --- a/source/src/Slackbot.Net.Endpoints/Slackbot.Net.Endpoints.csproj +++ b/source/src/Slackbot.Net.Endpoints/Slackbot.Net.Endpoints.csproj @@ -1,7 +1,7 @@ - + - netcoreapp3.1;net5.0 + netcoreapp3.1;net5.0;net6.0 Slackbot.Net.Endpoints Slackbot.Net.Endpoints John Korsnes @@ -20,7 +20,7 @@ - + @@ -29,6 +29,6 @@ - + diff --git a/source/src/Slackbot.Net.SlackClients.Http/Slackbot.Net.SlackClients.Http.csproj b/source/src/Slackbot.Net.SlackClients.Http/Slackbot.Net.SlackClients.Http.csproj index 7cc411d..278a0be 100644 --- a/source/src/Slackbot.Net.SlackClients.Http/Slackbot.Net.SlackClients.Http.csproj +++ b/source/src/Slackbot.Net.SlackClients.Http/Slackbot.Net.SlackClients.Http.csproj @@ -1,7 +1,7 @@ - + - netcoreapp3.1;net5.0 + netcoreapp3.1;net5.0;net6.0 Slackbot.Net.SlackClients.Http Slackbot.Net.SlackClients.Http John Korsnes @@ -27,8 +27,13 @@ - - + + + + + + +