diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index cffdbeab9..aef1517bb 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -8,10 +8,12 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Api; +using Bit.Core.Models.Business.Tokenables; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Bit.Sso.Models; using Bit.Sso.Utilities; @@ -47,6 +49,7 @@ namespace Bit.Sso.Controllers private readonly UserManager _userManager; private readonly IGlobalSettings _globalSettings; private readonly Core.Services.IEventService _eventService; + private readonly IDataProtectorTokenFactory _dataProtector; public AccountController( IAuthenticationSchemeProvider schemeProvider, @@ -64,7 +67,8 @@ namespace Bit.Sso.Controllers II18nService i18nService, UserManager userManager, IGlobalSettings globalSettings, - Core.Services.IEventService eventService) + Core.Services.IEventService eventService, + IDataProtectorTokenFactory dataProtector) { _schemeProvider = schemeProvider; _clientStore = clientStore; @@ -82,57 +86,47 @@ namespace Bit.Sso.Controllers _userManager = userManager; _eventService = eventService; _globalSettings = globalSettings; + _dataProtector = dataProtector; } [HttpGet] public async Task PreValidate(string domainHint) { - IActionResult invalidJson(string errorMessageKey, Exception ex = null) - { - Response.StatusCode = ex == null ? 400 : 500; - return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey)) - { - ExceptionMessage = ex?.Message, - ExceptionStackTrace = ex?.StackTrace, - InnerExceptionMessage = ex?.InnerException?.Message, - }); - } - try { // Validate domain_hint provided if (string.IsNullOrWhiteSpace(domainHint)) { - return invalidJson("NoOrganizationIdentifierProvidedError"); + return InvalidJson("NoOrganizationIdentifierProvidedError"); } // Validate organization exists from domain_hint var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); if (organization == null) { - return invalidJson("OrganizationNotFoundByIdentifierError"); + return InvalidJson("OrganizationNotFoundByIdentifierError"); } if (!organization.UseSso) { - return invalidJson("SsoNotAllowedForOrganizationError"); + return InvalidJson("SsoNotAllowedForOrganizationError"); } // Validate SsoConfig exists and is Enabled var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint); if (ssoConfig == null) { - return invalidJson("SsoConfigurationNotFoundForOrganizationError"); + return InvalidJson("SsoConfigurationNotFoundForOrganizationError"); } if (!ssoConfig.Enabled) { - return invalidJson("SsoNotEnabledForOrganizationError"); + return InvalidJson("SsoNotEnabledForOrganizationError"); } // Validate Authentication Scheme exists and is loaded (cache) var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString()); if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme)) { - return invalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError"); + return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError"); } // Run scheme validation @@ -148,37 +142,60 @@ namespace Bit.Sso.Controllers { errorKey = ex.Message; } - return invalidJson(errorKey, translatedException.ResourceNotFound ? ex : null); + return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null); } + + var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds); + var token = _dataProtector.Protect(tokenable); + + return new SsoPreValidateResponseModel(token); } catch (Exception ex) { - return invalidJson("PreValidationError", ex); + return InvalidJson("PreValidationError", ex); } - - // Everything is good! - return new EmptyResult(); } [HttpGet] public async Task Login(string returnUrl) { var context = await _interaction.GetAuthorizationContextAsync(returnUrl); - if (context.Parameters.AllKeys.Contains("domain_hint") && - !string.IsNullOrWhiteSpace(context.Parameters["domain_hint"])) - { - return RedirectToAction(nameof(ExternalChallenge), new - { - scheme = context.Parameters["domain_hint"], - returnUrl, - state = context.Parameters["state"], - userIdentifier = context.Parameters["session_state"] - }); - } - else + + if (!context.Parameters.AllKeys.Contains("domain_hint") || + string.IsNullOrWhiteSpace(context.Parameters["domain_hint"])) { throw new Exception(_i18nService.T("NoDomainHintProvided")); } + + var ssoToken = context.Parameters[SsoTokenable.TokenIdentifier]; + + if (string.IsNullOrWhiteSpace(ssoToken)) + { + return Unauthorized("A valid SSO token is required to continue with SSO login"); + } + + var domainHint = context.Parameters["domain_hint"]; + var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); + + if (organization == null) + { + return InvalidJson("OrganizationNotFoundByIdentifierError"); + } + + var tokenable = _dataProtector.Unprotect(ssoToken); + + if (!tokenable.TokenIsValid(organization)) + { + return Unauthorized("The SSO token associated with your request is expired. A valid SSO token is required to continue."); + } + + return RedirectToAction(nameof(ExternalChallenge), new + { + scheme = organization.Id.ToString(), + returnUrl, + state = context.Parameters["state"], + userIdentifier = context.Parameters["session_state"], + }); } [HttpGet] @@ -548,6 +565,17 @@ namespace Bit.Sso.Controllers return user; } + private IActionResult InvalidJson(string errorMessageKey, Exception ex = null) + { + Response.StatusCode = ex == null ? 400 : 500; + return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey)) + { + ExceptionMessage = ex?.Message, + ExceptionStackTrace = ex?.StackTrace, + InnerExceptionMessage = ex?.InnerException?.Message, + }); + } + private string GetEmailAddress(IEnumerable claims, IEnumerable additionalClaimTypes) { var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@")); diff --git a/bitwarden_license/src/Sso/Models/SsoPreValidateResponseModel.cs b/bitwarden_license/src/Sso/Models/SsoPreValidateResponseModel.cs new file mode 100644 index 000000000..9877e1c5a --- /dev/null +++ b/bitwarden_license/src/Sso/Models/SsoPreValidateResponseModel.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Sso.Models +{ + public class SsoPreValidateResponseModel : JsonResult + { + public SsoPreValidateResponseModel(string token) : base(new + { + token + }) + { } + } +} diff --git a/src/Core/Models/Business/Tokenables/SsoTokenable.cs b/src/Core/Models/Business/Tokenables/SsoTokenable.cs new file mode 100644 index 000000000..3eb9b1ae6 --- /dev/null +++ b/src/Core/Models/Business/Tokenables/SsoTokenable.cs @@ -0,0 +1,46 @@ + +using System; +using System.Text.Json.Serialization; +using Bit.Core.Entities; +using Bit.Core.Tokens; + +namespace Bit.Core.Models.Business.Tokenables +{ + public class SsoTokenable : ExpiringTokenable + { + public const string ClearTextPrefix = "BWUserPrefix_"; + public const string DataProtectorPurpose = "SsoTokenDataProtector"; + public const string TokenIdentifier = "ssoToken"; + + public Guid OrganizationId { get; set; } + public string DomainHint { get; set; } + public string Identifier { get; set; } = TokenIdentifier; + + [JsonConstructor] + public SsoTokenable() { } + + public SsoTokenable(Organization organization, double tokenLifetimeInSeconds) : this() + { + OrganizationId = organization?.Id ?? default; + DomainHint = organization?.Identifier; + ExpirationDate = DateTime.UtcNow.AddSeconds(tokenLifetimeInSeconds); + } + + public bool TokenIsValid(Organization organization) + { + if (OrganizationId == default || DomainHint == default || organization == null || !Valid) + { + return false; + } + + return organization.Identifier.Equals(DomainHint, StringComparison.InvariantCultureIgnoreCase) + && organization.Id.Equals(OrganizationId); + } + + // Validates deserialized + protected override bool TokenIsValid() => + Identifier == TokenIdentifier + && OrganizationId != default + && !string.IsNullOrWhiteSpace(DomainHint); + } +} diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index af0c6a109..eacc29d68 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -679,4 +679,10 @@ Single sign on service URL contains illegal characters. + + Single sign on redirect token is missing from the request. + + + Single sign on redirect token is invalid or expired. + \ No newline at end of file diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 8cf3a99c2..5b1a0e6c7 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -67,7 +67,7 @@ namespace Bit.Core.Settings public virtual AmazonSettings Amazon { get; set; } = new AmazonSettings(); public virtual ServiceBusSettings ServiceBus { get; set; } = new ServiceBusSettings(); public virtual AppleIapSettings AppleIap { get; set; } = new AppleIapSettings(); - public virtual SsoSettings Sso { get; set; } = new SsoSettings(); + public virtual ISsoSettings Sso { get; set; } = new SsoSettings(); public virtual StripeSettings Stripe { get; set; } = new StripeSettings(); public virtual ITwoFactorAuthSettings TwoFactorAuth { get; set; } = new TwoFactorAuthSettings(); @@ -461,9 +461,10 @@ namespace Bit.Core.Settings public bool AppInReview { get; set; } } - public class SsoSettings + public class SsoSettings : ISsoSettings { public int CacheLifetimeInSeconds { get; set; } = 60; + public double SsoTokenLifetimeInSeconds { get; set; } = 5; } public class CaptchaSettings diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index 8ccafdc1b..ec648384e 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -14,5 +14,6 @@ IConnectionStringSettings Storage { get; set; } IBaseServiceUriSettings BaseServiceUri { get; set; } ITwoFactorAuthSettings TwoFactorAuth { get; set; } + ISsoSettings Sso { get; set; } } } diff --git a/src/Core/Settings/ISsoSettings.cs b/src/Core/Settings/ISsoSettings.cs new file mode 100644 index 000000000..de5193cef --- /dev/null +++ b/src/Core/Settings/ISsoSettings.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Settings +{ + public interface ISsoSettings + { + int CacheLifetimeInSeconds { get; set; } + double SsoTokenLifetimeInSeconds { get; set; } + } +} diff --git a/src/Icons/packages.lock.json b/src/Icons/packages.lock.json index f7d7c85c6..06af2f1af 100644 --- a/src/Icons/packages.lock.json +++ b/src/Icons/packages.lock.json @@ -3368,4 +3368,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/Identity/Controllers/SsoController.cs b/src/Identity/Controllers/SsoController.cs index bb8251337..a7d0670e1 100644 --- a/src/Identity/Controllers/SsoController.cs +++ b/src/Identity/Controllers/SsoController.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using System.Threading.Tasks; using Bit.Core.Entities; using Bit.Core.Models.Api; +using Bit.Core.Models.Business.Tokenables; using Bit.Core.Repositories; using Bit.Identity.Models; using IdentityModel; @@ -59,14 +60,11 @@ namespace Bit.Identity.Controllers var culture = requestCultureFeature.RequestCulture.Culture.Name; var requestPath = $"/Account/PreValidate?domainHint={domainHint}&culture={culture}"; var httpClient = _clientFactory.CreateClient("InternalSso"); + + // Forward the internal SSO result using var responseMessage = await httpClient.GetAsync(requestPath); - if (responseMessage.IsSuccessStatusCode) - { - // All is good! - return new EmptyResult(); - } - Response.StatusCode = (int)responseMessage.StatusCode; var responseJson = await responseMessage.Content.ReadAsStringAsync(); + Response.StatusCode = (int)responseMessage.StatusCode; return Content(responseJson, "application/json"); } catch (Exception ex) @@ -89,6 +87,7 @@ namespace Bit.Identity.Controllers var domainHint = context.Parameters.AllKeys.Contains("domain_hint") ? context.Parameters["domain_hint"] : null; + var ssoToken = context.Parameters[SsoTokenable.TokenIdentifier]; if (string.IsNullOrWhiteSpace(domainHint)) { @@ -100,27 +99,28 @@ namespace Bit.Identity.Controllers return RedirectToAction(nameof(ExternalChallenge), new { - organizationIdentifier = domainHint, + domainHint = domainHint, returnUrl, - userIdentifier + userIdentifier, + ssoToken, }); } [HttpGet] - public async Task ExternalChallenge(string organizationIdentifier, string returnUrl, - string userIdentifier) + public async Task ExternalChallenge(string domainHint, string returnUrl, + string userIdentifier, string ssoToken) { - if (string.IsNullOrWhiteSpace(organizationIdentifier)) + if (string.IsNullOrWhiteSpace(domainHint)) { throw new Exception("Invalid organization reference id."); } - var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(organizationIdentifier); + var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint); if (ssoConfig == null || !ssoConfig.Enabled) { throw new Exception("Organization not found or SSO configuration not enabled"); } - var domainHint = ssoConfig.OrganizationId.ToString(); + var organizationId = ssoConfig.OrganizationId.ToString(); var scheme = "sso"; var props = new AuthenticationProperties @@ -130,8 +130,13 @@ namespace Bit.Identity.Controllers { { "return_url", returnUrl }, { "domain_hint", domainHint }, + { "organizationId", organizationId }, { "scheme", scheme }, }, + Parameters = + { + { "ssoToken", ssoToken }, + } }; if (!string.IsNullOrWhiteSpace(userIdentifier)) @@ -173,7 +178,7 @@ namespace Bit.Identity.Controllers IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1) }; - if (result.Properties != null && result.Properties.Items.TryGetValue("domain_hint", out var organization)) + if (result.Properties != null && result.Properties.Items.TryGetValue("organizationId", out var organization)) { additionalLocalClaims.Add(new Claim("organizationId", organization)); } diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 2b3d4afff..f1c76adaa 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using AspNetCoreRateLimit; using Bit.Core; using Bit.Core.Context; +using Bit.Core.Models.Business.Tokenables; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Identity.Utilities; @@ -110,10 +111,17 @@ namespace Bit.Identity { // Pass domain_hint onto the sso idp context.ProtocolMessage.DomainHint = context.Properties.Items["domain_hint"]; + context.ProtocolMessage.Parameters.Add("organizationId", context.Properties.Items["organizationId"]); if (context.Properties.Items.ContainsKey("user_identifier")) { context.ProtocolMessage.SessionState = context.Properties.Items["user_identifier"]; } + + if (context.Properties.Parameters.Count > 0 && context.Properties.Parameters.ContainsKey(SsoTokenable.TokenIdentifier)) + { + var token = context.Properties.Parameters[SsoTokenable.TokenIdentifier].ToString(); + context.ProtocolMessage.Parameters.Add(SsoTokenable.TokenIdentifier, token); + } return Task.FromResult(0); } }; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index c57011d0e..b78587b8d 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -121,6 +121,11 @@ namespace Bit.SharedWeb.Utilities HCaptchaTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider()) ); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + SsoTokenable.ClearTextPrefix, + SsoTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider())); } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) diff --git a/src/SharedWeb/packages.lock.json b/src/SharedWeb/packages.lock.json index 21a15ee40..9e1ddef45 100644 --- a/src/SharedWeb/packages.lock.json +++ b/src/SharedWeb/packages.lock.json @@ -3351,4 +3351,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUserDeleted.sql b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUserDeleted.sql index ae8780b9c..27c286889 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUserDeleted.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUserDeleted.sql @@ -11,6 +11,6 @@ BEGIN FROM [dbo].[OrganizationSponsorship] OS WHERE - [SponsoringOrganizationUserId] = @OrganizationUserId + [SponsoringOrganizationUserID] = @OrganizationUserId END GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUsersDeleted.sql b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUsersDeleted.sql index 203d40559..3f2478f02 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUsersDeleted.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUsersDeleted.sql @@ -11,6 +11,6 @@ BEGIN FROM [dbo].[OrganizationSponsorship] OS INNER JOIN - @SponsoringOrganizationUserIds I ON I.Id = OS.SponsoringOrganizationUserId + @SponsoringOrganizationUserIds I ON I.Id = OS.SponsoringOrganizationUserID END GO diff --git a/test/Core.Test/Models/Business/Tokenables/SsoTokenableTests.cs b/test/Core.Test/Models/Business/Tokenables/SsoTokenableTests.cs new file mode 100644 index 000000000..a7dee919b --- /dev/null +++ b/test/Core.Test/Models/Business/Tokenables/SsoTokenableTests.cs @@ -0,0 +1,90 @@ +using System; +using AutoFixture.Xunit2; +using Bit.Core.Entities; +using Bit.Core.Models.Business.Tokenables; +using Bit.Core.Tokens; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Models.Business.Tokenables +{ + public class SsoTokenableTests + { + [Fact] + public void CanHandleNullOrganization() + { + var token = new SsoTokenable(null, default); + + Assert.Equal(default, token.OrganizationId); + Assert.Equal(default, token.DomainHint); + } + + [Fact] + public void TokenWithNullOrganizationIsInvalid() + { + var token = new SsoTokenable(null, 500) + { + ExpirationDate = DateTime.UtcNow + TimeSpan.FromDays(1) + }; + + Assert.False(token.Valid); + } + + [Theory, BitAutoData] + public void TokenValidityCheckNullOrganizationIsInvalid(Organization organization) + { + var token = new SsoTokenable(organization, 500) + { + ExpirationDate = DateTime.UtcNow + TimeSpan.FromDays(1) + }; + + Assert.False(token.TokenIsValid(null)); + } + + [Theory, AutoData] + public void SetsDataFromOrganization(Organization organization) + { + var token = new SsoTokenable(organization, default); + + Assert.Equal(organization.Id, token.OrganizationId); + Assert.Equal(organization.Identifier, token.DomainHint); + } + + [Fact] + public void SetsExpirationFromConstructor() + { + var expectedDateTime = DateTime.UtcNow.AddSeconds(500); + var token = new SsoTokenable(null, 500); + + Assert.Equal(expectedDateTime, token.ExpirationDate, TimeSpan.FromMilliseconds(10)); + } + + [Theory, AutoData] + public void SerializationSetsCorrectDateTime(Organization organization) + { + var expectedDateTime = DateTime.UtcNow.AddHours(-5); + var token = new SsoTokenable(organization, default) + { + ExpirationDate = expectedDateTime + }; + + var result = Tokenable.FromToken(token.ToToken()); + + Assert.Equal(expectedDateTime, result.ExpirationDate, TimeSpan.FromMilliseconds(10)); + } + + [Theory, AutoData] + public void TokenIsValidFailsWhenExpired(Organization organization) + { + var expectedDateTime = DateTime.UtcNow.AddHours(-5); + var token = new SsoTokenable(organization, default) + { + ExpirationDate = expectedDateTime + }; + + var result = token.TokenIsValid(organization); + + Assert.False(result); + } + } +} diff --git a/util/Setup/packages.lock.json b/util/Setup/packages.lock.json index 3b6fef04f..e14db029b 100644 --- a/util/Setup/packages.lock.json +++ b/util/Setup/packages.lock.json @@ -3289,4 +3289,4 @@ } } } -} \ No newline at end of file +}