1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 13:08:17 -05:00

Refactored SlackOAuthController to use SlackService as an injected dependency; added tests for SlackService

This commit is contained in:
Brant DeBow 2025-03-19 10:23:49 -04:00
parent 2227b3b867
commit 297046be5b
No known key found for this signature in database
GPG Key ID: 94411BB25947C72B
12 changed files with 287 additions and 49 deletions

View File

@ -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<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("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}");
}
}

View File

@ -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<Core.HostedServices.ApplicationCacheHostedService>();
}
// Slack
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
}
public void Configure(

View File

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

View File

@ -5,5 +5,7 @@ public interface ISlackService
Task<string> GetChannelIdAsync(string token, string channelName);
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
Task<string> 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<string> ObtainTokenViaOAuth(string code, string redirectUrl);
}

View File

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

View File

@ -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<SlackService> 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<string> { 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<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
var tokenResponse = await _httpClient.PostAsync("https://slack.com/api/oauth.v2.access",
new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
}));
SlackOAuthResponse result;
try
{
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
}
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<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
{
var matchingChannelIds = new List<string>();
@ -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");

View File

@ -0,0 +1,36 @@
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
public class NoopSlackService : ISlackService
{
public Task<string> GetChannelIdAsync(string token, string channelName)
{
return Task.FromResult(string.Empty);
}
public Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
{
return Task.FromResult(new List<string>());
}
public Task<string> 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<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
return Task.FromResult(string.Empty);
}
}

View File

@ -286,8 +286,6 @@ public class GlobalSettings : IGlobalSettings
public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings();
public virtual List<SlackConfiguration> SlackConfigurations { get; set; } = new List<SlackConfiguration>();
public virtual List<WebhookConfiguration> WebhookConfigurations { get; set; } = new List<WebhookConfiguration>();
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();

View File

@ -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<IOrganizationIntegrationConfigurationRepository, OrganizationIntegrationConfigurationRepository>();
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
services.AddSingleton<SlackEventHandler>();
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(
provider.GetRequiredService<SlackEventHandler>(),
@ -131,6 +140,7 @@ public class Startup
globalSettings,
globalSettings.EventLogging.RabbitMq.SlackQueueName));
services.AddHttpClient(WebhookEventHandler.HttpClientName);
services.AddSingleton<WebhookEventHandler>();
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(

View File

@ -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<SlackOAuthController> sutProvider)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
var requestAction = await sutProvider.Sut.OAuthCallback(string.Empty);
Assert.IsType<BadRequestObjectResult>(requestAction);
}
[Theory, BitAutoData]
public async Task OAuthCallback_ThrowsBadResultWhenSlackServiceReturnsEmpty(SutProvider<SlackOAuthController> sutProvider)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(Arg.Any<string>(), Arg.Any<string>())
.Returns(string.Empty);
var requestAction = await sutProvider.Sut.OAuthCallback("A_test_code");
Assert.IsType<BadRequestObjectResult>(requestAction);
}
[Theory, BitAutoData]
public async Task OAuthCallback_CompletesSuccessfully(SutProvider<SlackOAuthController> sutProvider)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(Arg.Any<string>(), Arg.Any<string>())
.Returns("xoxb-test-token");
var requestAction = await sutProvider.Sut.OAuthCallback("A_test_code");
Assert.IsType<OkObjectResult>(requestAction);
}
[Theory, BitAutoData]
public void Redirect_ShouldRedirectToSlack(SutProvider<SlackOAuthController> sutProvider)
{
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>().GetRedirectUrl(Arg.Any<string>()).Returns(expectedUrl);
var requestAction = sutProvider.Sut.RedirectToSlack();
var redirectResult = Assert.IsType<RedirectResult>(requestAction);
Assert.Equal(expectedUrl, redirectResult.Url);
}
[Theory, BitAutoData]
public void Redirect_ThrowsBadResultWhenSlackServiceReturnsEmpty(SutProvider<SlackOAuthController> sutProvider)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>().GetRedirectUrl(Arg.Any<string>()).Returns(string.Empty);
var requestAction = sutProvider.Sut.RedirectToSlack();
Assert.IsType<BadRequestObjectResult>(requestAction);
}
}

View File

@ -86,7 +86,7 @@ public class SlackEventHandlerTests
var sutProvider = GetSutProvider(OneConfiguration());
await sutProvider.Sut.HandleEventAsync(eventMessage);
sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelId(
sutProvider.GetDependency<ISlackService>().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<ISlackService>().Received(1).SendSlackMessageByChannelId(
sutProvider.GetDependency<ISlackService>().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<ISlackService>().Received(1).SendSlackMessageByChannelId(
sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token2)),
Arg.Is(AssertHelper.AssertPropertyEqual(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")),

View File

@ -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<GlobalSettings>().Slack.ClientId;
var Scopes = sutProvider.GetDependency<GlobalSettings>().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];