diff --git a/src/Api/AdminConsole/Controllers/SlackOAuthController.cs b/src/Api/AdminConsole/Controllers/SlackOAuthController.cs index 3e05abaa29..dd8902b32b 100644 --- a/src/Api/AdminConsole/Controllers/SlackOAuthController.cs +++ b/src/Api/AdminConsole/Controllers/SlackOAuthController.cs @@ -1,29 +1,23 @@ -using System.Text.Json; -using Bit.Core.Settings; +using Bit.Core.Services; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; [Route("slack/oauth")] -public class SlackOAuthController( - IHttpClientFactory httpClientFactory, - GlobalSettings globalSettings) - : Controller +public class SlackOAuthController(ISlackService slackService) : Controller { - private readonly string _clientId = globalSettings.Slack.ClientId; - private readonly string _clientSecret = globalSettings.Slack.ClientSecret; - private readonly string _scopes = globalSettings.Slack.Scopes; - private readonly string _redirectUrl = globalSettings.Slack.RedirectUrl; - private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); - - public const string HttpClientName = "SlackOAuthContollerHttpClient"; - [HttpGet("redirect")] public IActionResult RedirectToSlack() { - string slackOAuthUrl = $"https://slack.com/oauth/v2/authorize?client_id={_clientId}&scope={_scopes}&redirect_uri={_redirectUrl}"; + string callbackUrl = Url.RouteUrl(nameof(OAuthCallback)); + var redirectUrl = slackService.GetRedirectUrl(callbackUrl); - return Redirect(slackOAuthUrl); + if (string.IsNullOrEmpty(redirectUrl)) + { + return BadRequest("Slack not currently supported."); + } + + return Redirect(redirectUrl); } [HttpGet("callback")] @@ -34,34 +28,20 @@ public class SlackOAuthController( return BadRequest("Missing code from Slack."); } - var tokenResponse = await _httpClient.PostAsync("https://slack.com/api/oauth.v2.access", - new FormUrlEncodedContent(new[] - { - new KeyValuePair("client_id", _clientId), - new KeyValuePair("client_secret", _clientSecret), - new KeyValuePair("code", code), - new KeyValuePair("redirect_uri", _redirectUrl) - })); + string callbackUrl = Url.RouteUrl(nameof(OAuthCallback)); + var token = await slackService.ObtainTokenViaOAuth(code, callbackUrl); - var responseBody = await tokenResponse.Content.ReadAsStringAsync(); - var jsonDoc = JsonDocument.Parse(responseBody); - var root = jsonDoc.RootElement; - - if (!root.GetProperty("ok").GetBoolean()) + if (string.IsNullOrEmpty(token)) { - return BadRequest($"OAuth failed: {root.GetProperty("error").GetString()}"); + return BadRequest("Invalid response from Slack."); } - string botToken = root.GetProperty("access_token").GetString(); - string teamId = root.GetProperty("team").GetProperty("id").GetString(); - - SaveTokenToDatabase(teamId, botToken); - + SaveTokenToDatabase(token); return Ok("Slack OAuth successful. Your bot is now installed."); } - private void SaveTokenToDatabase(string teamId, string botToken) + private void SaveTokenToDatabase(string botToken) { - Console.WriteLine($"Stored bot token for team {teamId}: {botToken}"); + Console.WriteLine($"Stored bot token for team: {botToken}"); } } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 5849bfb634..e4c8ed7db7 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -27,8 +27,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Services; using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ReportFeatures; @@ -212,6 +214,19 @@ public class Startup { services.AddHostedService(); } + + // Slack + if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) + { + services.AddHttpClient(SlackService.HttpClientName); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } } public void Configure( diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs index 3ce1b35846..59debed746 100644 --- a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs +++ b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs @@ -27,6 +27,18 @@ public class SlackUserResponse : SlackApiResponse public SlackUser User { get; set; } = new(); } +public class SlackOAuthResponse : SlackApiResponse +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + public SlackTeam Team { get; set; } = new(); +} + +public class SlackTeam +{ + public string Id { get; set; } = string.Empty; +} + public class SlackChannel { public string Id { get; set; } = string.Empty; diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs index 7dd9f42fb7..2209084bc6 100644 --- a/src/Core/AdminConsole/Services/ISlackService.cs +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -5,5 +5,7 @@ public interface ISlackService Task GetChannelIdAsync(string token, string channelName); Task> GetChannelIdsAsync(string token, List channelNames); Task GetDmChannelByEmailAsync(string token, string email); - Task SendSlackMessageByChannelId(string token, string message, string channelId); + string GetRedirectUrl(string redirectUrl); + Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); + Task ObtainTokenViaOAuth(string code, string redirectUrl); } diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs index 759148fe60..e3ef43c77f 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs @@ -20,7 +20,7 @@ public class SlackEventHandler( foreach (var configuration in configurations) { - await slackService.SendSlackMessageByChannelId( + await slackService.SendSlackMessageByChannelIdAsync( configuration.Configuration.Token, TemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), configuration.Configuration.ChannelId diff --git a/src/Core/AdminConsole/Services/Implementations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/SlackService.cs index 00acbfb633..ff812d633e 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/SlackService.cs @@ -2,15 +2,20 @@ using System.Net.Http.Json; using System.Web; using Bit.Core.Models.Slack; +using Bit.Core.Settings; using Microsoft.Extensions.Logging; namespace Bit.Core.Services; public class SlackService( IHttpClientFactory httpClientFactory, + GlobalSettings globalSettings, ILogger logger) : ISlackService { private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + private readonly string _clientId = globalSettings.Slack.ClientId; + private readonly string _clientSecret = globalSettings.Slack.ClientSecret; + private readonly string _scopes = globalSettings.Slack.Scopes; public const string HttpClientName = "SlackServiceHttpClient"; @@ -19,6 +24,46 @@ public class SlackService( return (await GetChannelIdsAsync(token, new List { channelName })).FirstOrDefault(); } + public string GetRedirectUrl(string redirectUrl) + { + return $"https://slack.com/oauth/v2/authorize?client_id={_clientId}&scope={_scopes}&redirect_uri={redirectUrl}"; + } + + public async Task ObtainTokenViaOAuth(string code, string redirectUrl) + { + var tokenResponse = await _httpClient.PostAsync("https://slack.com/api/oauth.v2.access", + new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", _clientId), + new KeyValuePair("client_secret", _clientSecret), + new KeyValuePair("code", code), + new KeyValuePair("redirect_uri", redirectUrl) + })); + + SlackOAuthResponse result; + try + { + result = await tokenResponse.Content.ReadFromJsonAsync(); + } + catch + { + result = null; + } + + if (result == null) + { + logger.LogError("Error obtaining token via OAuth: Unknown error"); + return string.Empty; + } + if (!result.Ok) + { + logger.LogError("Error obtaining token via OAuth: " + result.Error); + return string.Empty; + } + + return result.AccessToken; + } + public async Task> GetChannelIdsAsync(string token, List channelNames) { var matchingChannelIds = new List(); @@ -66,7 +111,7 @@ public class SlackService( return await OpenDmChannel(token, userId); } - public async Task SendSlackMessageByChannelId(string token, string message, string channelId) + public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) { var payload = JsonContent.Create(new { channel = channelId, text = message }); var request = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/chat.postMessage"); diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs new file mode 100644 index 0000000000..c34c073e87 --- /dev/null +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs @@ -0,0 +1,36 @@ +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.Services.NoopImplementations; + +public class NoopSlackService : ISlackService +{ + public Task GetChannelIdAsync(string token, string channelName) + { + return Task.FromResult(string.Empty); + } + + public Task> GetChannelIdsAsync(string token, List channelNames) + { + return Task.FromResult(new List()); + } + + public Task GetDmChannelByEmailAsync(string token, string email) + { + return Task.FromResult(string.Empty); + } + + public string GetRedirectUrl(string redirectUrl) + { + return string.Empty; + } + + public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) + { + return Task.FromResult(0); + } + + public Task ObtainTokenViaOAuth(string code, string redirectUrl) + { + return Task.FromResult(string.Empty); + } +} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index d4f385c47a..4667715e74 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -286,8 +286,6 @@ public class GlobalSettings : IGlobalSettings public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings(); public virtual List SlackConfigurations { get; set; } = new List(); public virtual List WebhookConfigurations { get; set; } = new List(); - public virtual string SlackChannel { get; set; } - public virtual string SlackToken { get; set; } public virtual string WebhookUrl { get; set; } public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 3a06f43487..9e505b1de4 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,5 +1,6 @@ using System.Globalization; using Bit.Core.AdminConsole.Services.Implementations; +using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Context; using Bit.Core.IdentityServer; using Bit.Core.Repositories; @@ -120,10 +121,18 @@ public class Startup services.AddSingleton(); - services.AddHttpClient(SlackService.HttpClientName); - services.AddSingleton(); + if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) + { + services.AddHttpClient(SlackService.HttpClientName); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } services.AddSingleton(); - services.AddSingleton(provider => new RabbitMqEventListenerService( provider.GetRequiredService(), @@ -131,6 +140,7 @@ public class Startup globalSettings, globalSettings.EventLogging.RabbitMq.SlackQueueName)); + services.AddHttpClient(WebhookEventHandler.HttpClientName); services.AddSingleton(); services.AddSingleton(provider => new RabbitMqEventListenerService( diff --git a/test/Api.Test/AdminConsole/Controllers/SlackOAuthControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/SlackOAuthControllerTests.cs new file mode 100644 index 0000000000..6d9e4c166c --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/SlackOAuthControllerTests.cs @@ -0,0 +1,75 @@ +using Bit.Api.AdminConsole.Controllers; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(SlackOAuthController))] +[SutProviderCustomize] +public class SlackOAuthControllerTests +{ + [Theory, BitAutoData] + public async Task OAuthCallback_ThrowsBadResultWhenCodeIsEmpty(SutProvider sutProvider) + { + sutProvider.Sut.Url = Substitute.For(); + + var requestAction = await sutProvider.Sut.OAuthCallback(string.Empty); + + Assert.IsType(requestAction); + } + + [Theory, BitAutoData] + public async Task OAuthCallback_ThrowsBadResultWhenSlackServiceReturnsEmpty(SutProvider sutProvider) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) + .Returns(string.Empty); + + var requestAction = await sutProvider.Sut.OAuthCallback("A_test_code"); + + Assert.IsType(requestAction); + } + + [Theory, BitAutoData] + public async Task OAuthCallback_CompletesSuccessfully(SutProvider sutProvider) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) + .Returns("xoxb-test-token"); + + var requestAction = await sutProvider.Sut.OAuthCallback("A_test_code"); + + Assert.IsType(requestAction); + } + + [Theory, BitAutoData] + public void Redirect_ShouldRedirectToSlack(SutProvider sutProvider) + { + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(expectedUrl); + + var requestAction = sutProvider.Sut.RedirectToSlack(); + + var redirectResult = Assert.IsType(requestAction); + Assert.Equal(expectedUrl, redirectResult.Url); + } + + [Theory, BitAutoData] + public void Redirect_ThrowsBadResultWhenSlackServiceReturnsEmpty(SutProvider sutProvider) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(string.Empty); + + var requestAction = sutProvider.Sut.RedirectToSlack(); + + Assert.IsType(requestAction); + } +} diff --git a/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs index 040e269359..3048088215 100644 --- a/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs @@ -86,7 +86,7 @@ public class SlackEventHandlerTests var sutProvider = GetSutProvider(OneConfiguration()); await sutProvider.Sut.HandleEventAsync(eventMessage); - sutProvider.GetDependency().Received(1).SendSlackMessageByChannelId( + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( Arg.Is(AssertHelper.AssertPropertyEqual(_token)), Arg.Is(AssertHelper.AssertPropertyEqual( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), @@ -100,13 +100,13 @@ public class SlackEventHandlerTests var sutProvider = GetSutProvider(TwoConfigurations()); await sutProvider.Sut.HandleEventAsync(eventMessage); - sutProvider.GetDependency().Received(1).SendSlackMessageByChannelId( + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( Arg.Is(AssertHelper.AssertPropertyEqual(_token)), Arg.Is(AssertHelper.AssertPropertyEqual( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) ); - sutProvider.GetDependency().Received(1).SendSlackMessageByChannelId( + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( Arg.Is(AssertHelper.AssertPropertyEqual(_token2)), Arg.Is(AssertHelper.AssertPropertyEqual( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), diff --git a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs index 25b6261015..068848ba74 100644 --- a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs @@ -6,6 +6,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.MockedHttpClient; using NSubstitute; using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; namespace Bit.Core.Test.Services; @@ -195,6 +196,70 @@ public class SlackServiceTests Assert.Equal(dmChannelId, result); } + + [Fact] + public void GetRedirectUrl_ReturnsCorrectUrl() + { + var sutProvider = GetSutProvider(); + var ClientId = sutProvider.GetDependency().Slack.ClientId; + var Scopes = sutProvider.GetDependency().Slack.Scopes; + var redirectUrl = "https://example.com/callback"; + var expectedUrl = $"https://slack.com/oauth/v2/authorize?client_id={ClientId}&scope={Scopes}&redirect_uri={redirectUrl}"; + var result = sutProvider.Sut.GetRedirectUrl(redirectUrl); + Assert.Equal(expectedUrl, result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_ReturnsAccessToken_WhenSuccessful() + { + var sutProvider = GetSutProvider(); + var jsonResponse = JsonSerializer.Serialize(new + { + ok = true, + access_token = "test-access-token" + }); + + _handler.When("https://slack.com/api/oauth.v2.access") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal("test-access-token", result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenErrorResponse() + { + var sutProvider = GetSutProvider(); + var jsonResponse = JsonSerializer.Serialize(new + { + ok = false, + error = "invalid_code" + }); + + _handler.When("https://slack.com/api/oauth.v2.access") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenHttpCallFails() + { + var sutProvider = GetSutProvider(); + _handler.When("https://slack.com/api/oauth.v2.access") + .RespondWith(HttpStatusCode.InternalServerError) + .WithContent(new StringContent(string.Empty)); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal(string.Empty, result); + } + [Fact] public async Task SendSlackMessageByChannelId_Sends_Correct_Message() { @@ -206,7 +271,7 @@ public class SlackServiceTests .RespondWith(HttpStatusCode.OK) .WithContent(new StringContent(string.Empty)); - await sutProvider.Sut.SendSlackMessageByChannelId(_token, message, channelId); + await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); Assert.Single(_handler.CapturedRequests); var request = _handler.CapturedRequests[0];