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.Services;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
[Route("slack/oauth")] [Route("slack/oauth")]
public class SlackOAuthController( public class SlackOAuthController(ISlackService slackService) : Controller
IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings)
: 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")] [HttpGet("redirect")]
public IActionResult RedirectToSlack() 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")] [HttpGet("callback")]
@ -34,34 +28,20 @@ public class SlackOAuthController(
return BadRequest("Missing code from Slack."); return BadRequest("Missing code from Slack.");
} }
var tokenResponse = await _httpClient.PostAsync("https://slack.com/api/oauth.v2.access", string callbackUrl = Url.RouteUrl(nameof(OAuthCallback));
new FormUrlEncodedContent(new[] var token = await slackService.ObtainTokenViaOAuth(code, callbackUrl);
{
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)
}));
var responseBody = await tokenResponse.Content.ReadAsStringAsync(); if (string.IsNullOrEmpty(token))
var jsonDoc = JsonDocument.Parse(responseBody);
var root = jsonDoc.RootElement;
if (!root.GetProperty("ok").GetBoolean())
{ {
return BadRequest($"OAuth failed: {root.GetProperty("error").GetString()}"); return BadRequest("Invalid response from Slack.");
} }
string botToken = root.GetProperty("access_token").GetString(); SaveTokenToDatabase(token);
string teamId = root.GetProperty("team").GetProperty("id").GetString();
SaveTokenToDatabase(teamId, botToken);
return Ok("Slack OAuth successful. Your bot is now installed."); 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.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures; using Bit.Core.Tools.ReportFeatures;
@ -212,6 +214,19 @@ public class Startup
{ {
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>(); 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( public void Configure(

View File

@ -27,6 +27,18 @@ public class SlackUserResponse : SlackApiResponse
public SlackUser User { get; set; } = new(); 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 class SlackChannel
{ {
public string Id { get; set; } = string.Empty; public string Id { get; set; } = string.Empty;

View File

@ -5,5 +5,7 @@ public interface ISlackService
Task<string> GetChannelIdAsync(string token, string channelName); Task<string> GetChannelIdAsync(string token, string channelName);
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames); Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
Task<string> GetDmChannelByEmailAsync(string token, string email); 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) foreach (var configuration in configurations)
{ {
await slackService.SendSlackMessageByChannelId( await slackService.SendSlackMessageByChannelIdAsync(
configuration.Configuration.Token, configuration.Configuration.Token,
TemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), TemplateProcessor.ReplaceTokens(configuration.Template, eventMessage),
configuration.Configuration.ChannelId configuration.Configuration.ChannelId

View File

@ -2,15 +2,20 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Web; using System.Web;
using Bit.Core.Models.Slack; using Bit.Core.Models.Slack;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Bit.Core.Services; namespace Bit.Core.Services;
public class SlackService( public class SlackService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings,
ILogger<SlackService> logger) : ISlackService ILogger<SlackService> logger) : ISlackService
{ {
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); 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"; public const string HttpClientName = "SlackServiceHttpClient";
@ -19,6 +24,46 @@ public class SlackService(
return (await GetChannelIdsAsync(token, new List<string> { channelName })).FirstOrDefault(); 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) public async Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
{ {
var matchingChannelIds = new List<string>(); var matchingChannelIds = new List<string>();
@ -66,7 +111,7 @@ public class SlackService(
return await OpenDmChannel(token, userId); 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 payload = JsonContent.Create(new { channel = channelId, text = message });
var request = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/chat.postMessage"); 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 AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings();
public virtual List<SlackConfiguration> SlackConfigurations { get; set; } = new List<SlackConfiguration>(); public virtual List<SlackConfiguration> SlackConfigurations { get; set; } = new List<SlackConfiguration>();
public virtual List<WebhookConfiguration> WebhookConfigurations { get; set; } = new List<WebhookConfiguration>(); 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 virtual string WebhookUrl { get; set; }
public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings();

View File

@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.Implementations;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -120,10 +121,18 @@ public class Startup
services.AddSingleton<IOrganizationIntegrationConfigurationRepository, OrganizationIntegrationConfigurationRepository>(); services.AddSingleton<IOrganizationIntegrationConfigurationRepository, OrganizationIntegrationConfigurationRepository>();
services.AddHttpClient(SlackService.HttpClientName); if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
services.AddSingleton<ISlackService, SlackService>(); 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<SlackEventHandler>();
services.AddSingleton<IHostedService>(provider => services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService( new RabbitMqEventListenerService(
provider.GetRequiredService<SlackEventHandler>(), provider.GetRequiredService<SlackEventHandler>(),
@ -131,6 +140,7 @@ public class Startup
globalSettings, globalSettings,
globalSettings.EventLogging.RabbitMq.SlackQueueName)); globalSettings.EventLogging.RabbitMq.SlackQueueName));
services.AddHttpClient(WebhookEventHandler.HttpClientName);
services.AddSingleton<WebhookEventHandler>(); services.AddSingleton<WebhookEventHandler>();
services.AddSingleton<IHostedService>(provider => services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService( 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()); var sutProvider = GetSutProvider(OneConfiguration());
await sutProvider.Sut.HandleEventAsync(eventMessage); 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(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual( Arg.Is(AssertHelper.AssertPropertyEqual(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")),
@ -100,13 +100,13 @@ public class SlackEventHandlerTests
var sutProvider = GetSutProvider(TwoConfigurations()); var sutProvider = GetSutProvider(TwoConfigurations());
await sutProvider.Sut.HandleEventAsync(eventMessage); 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(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual( Arg.Is(AssertHelper.AssertPropertyEqual(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) 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(_token2)),
Arg.Is(AssertHelper.AssertPropertyEqual( Arg.Is(AssertHelper.AssertPropertyEqual(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), $"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 Bit.Test.Common.MockedHttpClient;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Services; namespace Bit.Core.Test.Services;
@ -195,6 +196,70 @@ public class SlackServiceTests
Assert.Equal(dmChannelId, result); 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] [Fact]
public async Task SendSlackMessageByChannelId_Sends_Correct_Message() public async Task SendSlackMessageByChannelId_Sends_Correct_Message()
{ {
@ -206,7 +271,7 @@ public class SlackServiceTests
.RespondWith(HttpStatusCode.OK) .RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(string.Empty)); .WithContent(new StringContent(string.Empty));
await sutProvider.Sut.SendSlackMessageByChannelId(_token, message, channelId); await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
Assert.Single(_handler.CapturedRequests); Assert.Single(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0]; var request = _handler.CapturedRequests[0];