1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 20:50:21 -05:00

Moved Slack OAuth to take into account the Organization it's being stored for. Added methods to store the top level integration for Slack

This commit is contained in:
Brant DeBow 2025-03-24 11:13:23 -04:00
parent 61a621b04b
commit ffda25608c
No known key found for this signature in database
GPG Key ID: 94411BB25947C72B
13 changed files with 200 additions and 124 deletions

View File

@ -1,31 +1,52 @@
using Bit.Core.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[Route("slack/oauth")]
public class SlackOAuthController(ISlackService slackService) : Controller
[Authorize("Application")]
public class SlackOAuthController(
ICurrentContext currentContext,
IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository,
ISlackService slackService) : Controller
{
[HttpGet("redirect")]
public IActionResult RedirectToSlack()
[HttpGet("redirect/{id}")]
public async Task<IActionResult> RedirectToSlack(string id)
{
string callbackUrl = Url.RouteUrl(nameof(OAuthCallback));
var orgIdGuid = new Guid(id);
if (!await currentContext.OrganizationOwner(orgIdGuid))
{
throw new NotFoundException();
}
string callbackUrl = Url.RouteUrl(nameof(OAuthCallback), new { id = id }, currentContext.HttpContext.Request.Scheme);
var redirectUrl = slackService.GetRedirectUrl(callbackUrl);
if (string.IsNullOrEmpty(redirectUrl))
{
return BadRequest("Slack not currently supported.");
throw new NotFoundException();
}
return Redirect(redirectUrl);
}
[HttpGet("callback")]
public async Task<IActionResult> OAuthCallback([FromQuery] string code)
[HttpGet("callback/{id}", Name = nameof(OAuthCallback))]
public async Task<IActionResult> OAuthCallback(string id, [FromQuery] string code)
{
var orgIdGuid = new Guid(id);
if (!await currentContext.OrganizationOwner(orgIdGuid))
{
throw new NotFoundException();
}
if (string.IsNullOrEmpty(code))
{
return BadRequest("Missing code from Slack.");
throw new BadRequestException("Missing code from Slack.");
}
string callbackUrl = Url.RouteUrl(nameof(OAuthCallback));
@ -33,15 +54,13 @@ public class SlackOAuthController(ISlackService slackService) : Controller
if (string.IsNullOrEmpty(token))
{
return BadRequest("Invalid response from Slack.");
throw new BadRequestException("Invalid response from Slack.");
}
SaveTokenToDatabase(token);
await integrationConfigurationRepository.CreateOrganizationIntegrationAsync(
orgIdGuid,
IntegrationType.Slack,
new SlackIntegration(token));
return Ok("Slack OAuth successful. Your bot is now installed.");
}
private void SaveTokenToDatabase(string botToken)
{
Console.WriteLine($"Stored bot token for team: {botToken}");
}
}

View File

@ -30,6 +30,7 @@ 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.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures;
@ -222,10 +223,14 @@ public class Startup
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
services.AddSingleton<IOrganizationIntegrationConfigurationRepository,
LocalOrganizationIntegrationConfigurationRepository>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
services.AddSingleton<IOrganizationIntegrationConfigurationRepository,
LocalOrganizationIntegrationConfigurationRepository>();
}
}

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegration(string token);

View File

@ -1,12 +1,17 @@
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
#nullable enable
namespace Bit.Core.Repositories;
public interface IOrganizationIntegrationConfigurationRepository
{
Task<List<IntegrationConfiguration<T>>> GetConfigurationsAsync<T>(IntegrationType integrationType,
Guid organizationId, EventType eventType);
Task<List<IntegrationConfiguration<T>>> GetConfigurationsAsync<T>(
Guid organizationId,
IntegrationType integrationType,
EventType eventType);
Task CreateOrganizationIntegrationAsync<T>(
Guid organizationId,
IntegrationType integrationType,
T configuration);
}

View File

@ -1,14 +1,15 @@
using Bit.Core.Enums;
using System.Text.Json;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Settings;
namespace Bit.Core.Repositories;
public class OrganizationIntegrationConfigurationRepository(GlobalSettings globalSettings)
public class LocalOrganizationIntegrationConfigurationRepository(GlobalSettings globalSettings)
: IOrganizationIntegrationConfigurationRepository
{
public async Task<List<IntegrationConfiguration<T>>> GetConfigurationsAsync<T>(IntegrationType integrationType,
Guid organizationId,
public async Task<List<IntegrationConfiguration<T>>> GetConfigurationsAsync<T>(Guid organizationId,
IntegrationType integrationType,
EventType eventType)
{
var configurations = new List<IntegrationConfiguration<T>>();
@ -39,13 +40,13 @@ public class OrganizationIntegrationConfigurationRepository(GlobalSettings globa
return configurations;
}
public async Task<IEnumerable<IntegrationConfiguration<T>>> GetAllConfigurationsAsync<T>(Guid organizationId) => throw new NotImplementedException();
public async Task CreateOrganizationIntegrationAsync<T>(
Guid organizationId,
IntegrationType integrationType,
T configuration)
{
var json = JsonSerializer.Serialize(configuration);
public async Task AddConfigurationAsync<T>(Guid organizationId, IntegrationType integrationType, EventType eventType,
IntegrationConfiguration<T> configuration) =>
throw new NotImplementedException();
public async Task UpdateConfigurationAsync<T>(IntegrationConfiguration<T> configuration) => throw new NotImplementedException();
public async Task DeleteConfigurationAsync(Guid id) => throw new NotImplementedException();
Console.WriteLine($"Organization: {organizationId}, IntegrationType: {integrationType}, Configuration: {json}");
}
}

View File

@ -6,6 +6,6 @@ public interface ISlackService
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
Task<string> GetDmChannelByEmailAsync(string token, string email);
string GetRedirectUrl(string redirectUrl);
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
}

View File

@ -13,10 +13,7 @@ public class SlackEventHandler(
public async Task HandleEventAsync(EventMessage eventMessage)
{
var organizationId = eventMessage.OrganizationId ?? Guid.NewGuid();
var configurations = await configurationRepository.GetConfigurationsAsync<SlackConfiguration>(
IntegrationType.Slack,
organizationId, eventMessage.Type
);
var configurations = await configurationRepository.GetConfigurationsAsync<SlackConfiguration>(organizationId, IntegrationType.Slack, eventMessage.Type);
foreach (var configuration in configurations)
{

View File

@ -24,46 +24,6 @@ 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>();
@ -111,6 +71,46 @@ public class SlackService(
return await OpenDmChannel(token, userId);
}
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 SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
{
var payload = JsonContent.Create(new { channel = channelId, text = message });

View File

@ -21,11 +21,8 @@ public class WebhookEventHandler(
{
Guid organizationId = eventMessage.OrganizationId ?? Guid.NewGuid();
var configurations = await configurationRepository.GetConfigurationsAsync<WebhookConfiguration>(
IntegrationType.Webhook,
organizationId,
eventMessage.Type
);
var configurations = await configurationRepository.GetConfigurationsAsync<WebhookConfiguration>(organizationId,
IntegrationType.Webhook, eventMessage.Type);
foreach (var configuration in configurations)
{

View File

@ -119,7 +119,7 @@ public class Startup
globalSettings,
globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName));
services.AddSingleton<IOrganizationIntegrationConfigurationRepository, OrganizationIntegrationConfigurationRepository>();
services.AddSingleton<IOrganizationIntegrationConfigurationRepository, LocalOrganizationIntegrationConfigurationRepository>();
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&

View File

@ -1,4 +1,9 @@
using Bit.Api.AdminConsole.Controllers;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -13,63 +18,112 @@ namespace Bit.Api.Test.AdminConsole.Controllers;
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)
public async Task OAuthCallback_CompletesSuccessfully(SutProvider<SlackOAuthController> sutProvider, Guid organizationId)
{
var token = "xoxb-test-token";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(Arg.Any<string>(), Arg.Any<string>())
.Returns(string.Empty);
.Returns(token);
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");
var requestAction = await sutProvider.Sut.OAuthCallback(organizationId.ToString(), "A_test_code");
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateOrganizationIntegrationAsync(organizationId, IntegrationType.Slack, new SlackIntegration(token));
Assert.IsType<OkObjectResult>(requestAction);
}
[Theory, BitAutoData]
public void Redirect_ShouldRedirectToSlack(SutProvider<SlackOAuthController> sutProvider)
public async Task OAuthCallback_ThrowsBadResultWhenCodeIsEmpty(SutProvider<SlackOAuthController> sutProvider, Guid organizationId)
{
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.OAuthCallback(organizationId.ToString(), string.Empty));
}
[Theory, BitAutoData]
public async Task OAuthCallback_ThrowsBadResultWhenSlackServiceReturnsEmpty(SutProvider<SlackOAuthController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(Arg.Any<string>(), Arg.Any<string>())
.Returns(string.Empty);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.OAuthCallback(organizationId.ToString(), "A_test_code"));
}
[Theory, BitAutoData]
public async Task OAuthCallback_ThrowsNotFoundIfUserIsNotOrganizationAdmin(SutProvider<SlackOAuthController> sutProvider, Guid organizationId)
{
var token = "xoxb-test-token";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(Arg.Any<string>(), Arg.Any<string>())
.Returns(token);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.OAuthCallback(organizationId.ToString(), "A_test_code"));
}
[Theory, BitAutoData]
public async Task Redirect_ShouldRedirectToSlack(SutProvider<SlackOAuthController> sutProvider, Guid organizationId)
{
var expectedUrl = $"https://localhost/{organizationId.ToString()}";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>().GetRedirectUrl(Arg.Any<string>()).Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.HttpContext.Request.Scheme
.Returns("https");
var requestAction = sutProvider.Sut.RedirectToSlack();
var requestAction = await sutProvider.Sut.RedirectToSlack(organizationId.ToString());
var redirectResult = Assert.IsType<RedirectResult>(requestAction);
Assert.Equal(expectedUrl, redirectResult.Url);
}
[Theory, BitAutoData]
public void Redirect_ThrowsBadResultWhenSlackServiceReturnsEmpty(SutProvider<SlackOAuthController> sutProvider)
public async Task Redirect_ThrowsNotFoundWhenSlackServiceReturnsEmpty(SutProvider<SlackOAuthController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>().GetRedirectUrl(Arg.Any<string>()).Returns(string.Empty);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.HttpContext.Request.Scheme
.Returns("https");
var requestAction = sutProvider.Sut.RedirectToSlack();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectToSlack(organizationId.ToString()));
}
Assert.IsType<BadRequestObjectResult>(requestAction);
[Theory, BitAutoData]
public async Task Redirect_ThrowsNotFoundWhenUserIsNotOrganizationAdmin(SutProvider<SlackOAuthController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>().GetRedirectUrl(Arg.Any<string>()).Returns(string.Empty);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
sutProvider.GetDependency<ICurrentContext>()
.HttpContext.Request.Scheme
.Returns("https");
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectToSlack(organizationId.ToString()));
}
}

View File

@ -24,10 +24,8 @@ public class SlackEventHandlerTests
private SutProvider<SlackEventHandler> GetSutProvider(
List<IntegrationConfiguration<SlackConfiguration>> integrationConfigurations)
{
_repository.GetConfigurationsAsync<SlackConfiguration>(
IntegrationType.Slack,
Arg.Any<Guid>(),
Arg.Any<EventType>())
_repository.GetConfigurationsAsync<SlackConfiguration>(Arg.Any<Guid>(),
IntegrationType.Slack, Arg.Any<EventType>())
.Returns(integrationConfigurations);
return new SutProvider<SlackEventHandler>()

View File

@ -47,11 +47,8 @@ public class WebhookEventHandlerTests
clientFactory.CreateClient(WebhookEventHandler.HttpClientName).Returns(_httpClient);
var repository = Substitute.For<IOrganizationIntegrationConfigurationRepository>();
repository.GetConfigurationsAsync<WebhookConfiguration>(
IntegrationType.Webhook,
Arg.Any<Guid>(),
Arg.Any<EventType>()
).Returns(configurations);
repository.GetConfigurationsAsync<WebhookConfiguration>(Arg.Any<Guid>(),
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations);
return new SutProvider<WebhookEventHandler>()
.SetDependency(repository)