From 66e44759f0a39761e784686e321f1467364282ff Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Mon, 26 Oct 2020 11:56:16 -0500 Subject: [PATCH] [Require SSO] Enterprise policy enforcement (#970) * Initial commit of require sso authentication policy enforcement * Updated sproc to send UseSso flag // Updated base validator to send back error message // Added changes to EntityFramework (just so its there for the future * Update policy name // adjusted conditional to demorgan's * Updated sproc // Added migrator script * Added .sql file extension to DeleteOrgUserWithOrg migrator script * Added policy // edit // strings // validation to business portal * Change requests from review // Added Owner & Admin exemption * Updated repository function used to get org user's type * Updated with requested changes --- .../Portal/Controllers/PoliciesController.cs | 39 +++++++++- .../src/Portal/Models/PoliciesModel.cs | 6 +- .../src/Portal/Models/PolicyEditModel.cs | 1 + .../src/Portal/Models/PolicyModel.cs | 5 ++ .../src/Portal/Views/Policies/Edit.cshtml | 13 ++++ src/Core/Enums/PolicyType.cs | 1 + .../IdentityServer/BaseRequestValidator.cs | 74 ++++++++++++++++++- .../CustomTokenRequestValidator.cs | 8 +- .../ResourceOwnerPasswordValidator.cs | 12 ++- src/Core/Models/Data/OrganizationAbility.cs | 2 + .../EntityFramework/OrganizationRepository.cs | 1 + src/Core/Resources/SharedResources.en.resx | 15 ++++ .../Organization_ReadAbilities.sql | 1 + ...=> 2020-10-08_00_DeleteOrgUserWithOrg.sql} | 0 .../2020-10-20_00_OrgReadAbilities.sql | 28 +++++++ 15 files changed, 195 insertions(+), 11 deletions(-) rename util/Migrator/DbScripts/{2020-10-08_00_DeleteOrgUserWithOrg => 2020-10-08_00_DeleteOrgUserWithOrg.sql} (100%) create mode 100644 util/Migrator/DbScripts/2020-10-20_00_OrgReadAbilities.sql diff --git a/bitwarden_license/src/Portal/Controllers/PoliciesController.cs b/bitwarden_license/src/Portal/Controllers/PoliciesController.cs index 85be34494e..41edf0b39f 100644 --- a/bitwarden_license/src/Portal/Controllers/PoliciesController.cs +++ b/bitwarden_license/src/Portal/Controllers/PoliciesController.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Bit.Core.Enums; using Bit.Core.Models.Table; using Bit.Core.Repositories; @@ -50,7 +51,8 @@ namespace Bit.Portal.Controllers } var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgId.Value); - return View(new PoliciesModel(policies)); + var selectedOrgUseSso = _enterprisePortalCurrentContext.SelectedOrganizationDetails.UseSso; + return View(new PoliciesModel(policies, selectedOrgUseSso)); } [HttpGet("/edit/{type}")] @@ -88,6 +90,7 @@ namespace Bit.Portal.Controllers return Redirect("~/"); } + await ValidateDependentPolicies(type, orgId, model.Enabled); var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId.Value, type); if (!ModelState.IsValid) { @@ -119,5 +122,37 @@ namespace Bit.Portal.Controllers return View(new PolicyEditModel(policy, _i18nService)); } } + + private async Task ValidateDependentPolicies(PolicyType type, Guid? orgId, bool enabled) + { + if (orgId == null) + { + throw new ArgumentNullException(nameof(orgId), "OrgId cannot be null"); + } + + switch(type) + { + case PolicyType.MasterPassword: + case PolicyType.PasswordGenerator: + case PolicyType.TwoFactorAuthentication: + case PolicyType.OnlyOrg: + break; + + case PolicyType.RequireSso: + if (!enabled) + { + break; + } + var singleOrg = await _policyRepository.GetByOrganizationIdTypeAsync(orgId.Value, type); + if (singleOrg?.Enabled != true) + { + ModelState.AddModelError(string.Empty, _i18nService.T("RequireSsoPolicyReqError")); + } + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } } } diff --git a/bitwarden_license/src/Portal/Models/PoliciesModel.cs b/bitwarden_license/src/Portal/Models/PoliciesModel.cs index e27c8533cd..10d3d4acc1 100644 --- a/bitwarden_license/src/Portal/Models/PoliciesModel.cs +++ b/bitwarden_license/src/Portal/Models/PoliciesModel.cs @@ -8,7 +8,7 @@ namespace Bit.Portal.Models { public class PoliciesModel { - public PoliciesModel(ICollection policies) + public PoliciesModel(ICollection policies, bool useSso) { if (policies == null) { @@ -20,6 +20,10 @@ namespace Bit.Portal.Models foreach (var type in Enum.GetValues(typeof(PolicyType)).Cast()) { + if (type == PolicyType.RequireSso && !useSso) + { + continue; + } var enabled = policyDict.ContainsKey(type) ? policyDict[type].Enabled : false; Policies.Add(new PolicyModel(type, enabled)); } diff --git a/bitwarden_license/src/Portal/Models/PolicyEditModel.cs b/bitwarden_license/src/Portal/Models/PolicyEditModel.cs index 94610d1605..671c5ef68b 100644 --- a/bitwarden_license/src/Portal/Models/PolicyEditModel.cs +++ b/bitwarden_license/src/Portal/Models/PolicyEditModel.cs @@ -83,6 +83,7 @@ namespace Bit.Portal.Models break; case PolicyType.OnlyOrg: case PolicyType.TwoFactorAuthentication: + case PolicyType.RequireSso: break; default: throw new ArgumentOutOfRangeException(); diff --git a/bitwarden_license/src/Portal/Models/PolicyModel.cs b/bitwarden_license/src/Portal/Models/PolicyModel.cs index 01f24c595e..4877dc5c78 100644 --- a/bitwarden_license/src/Portal/Models/PolicyModel.cs +++ b/bitwarden_license/src/Portal/Models/PolicyModel.cs @@ -36,6 +36,11 @@ namespace Bit.Portal.Models NameKey = "OnlyOrganization"; DescriptionKey = "OnlyOrganizationDescription"; break; + + case PolicyType.RequireSso: + NameKey = "RequireSso"; + DescriptionKey = "RequireSsoDescription"; + break; default: throw new ArgumentOutOfRangeException(); diff --git a/bitwarden_license/src/Portal/Views/Policies/Edit.cshtml b/bitwarden_license/src/Portal/Views/Policies/Edit.cshtml index 0f389691c7..71db37a0c0 100644 --- a/bitwarden_license/src/Portal/Views/Policies/Edit.cshtml +++ b/bitwarden_license/src/Portal/Views/Policies/Edit.cshtml @@ -32,6 +32,19 @@ @i18nService.T("OnlyOrganizationPolicyWarning") } + + @if (Model.PolicyType == PolicyType.RequireSso) + { + + } + +
diff --git a/src/Core/Enums/PolicyType.cs b/src/Core/Enums/PolicyType.cs index 7735470373..783a92ffae 100644 --- a/src/Core/Enums/PolicyType.cs +++ b/src/Core/Enums/PolicyType.cs @@ -6,5 +6,6 @@ MasterPassword = 1, PasswordGenerator = 2, OnlyOrg = 3, + RequireSso = 4, } } diff --git a/src/Core/IdentityServer/BaseRequestValidator.cs b/src/Core/IdentityServer/BaseRequestValidator.cs index e67993d7cd..2fad680315 100644 --- a/src/Core/IdentityServer/BaseRequestValidator.cs +++ b/src/Core/IdentityServer/BaseRequestValidator.cs @@ -35,6 +35,7 @@ namespace Bit.Core.IdentityServer private readonly ILogger _logger; private readonly CurrentContext _currentContext; private readonly GlobalSettings _globalSettings; + private readonly IPolicyRepository _policyRepository; public BaseRequestValidator( UserManager userManager, @@ -49,7 +50,8 @@ namespace Bit.Core.IdentityServer IMailService mailService, ILogger logger, CurrentContext currentContext, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IPolicyRepository policyRepository) { _userManager = userManager; _deviceRepository = deviceRepository; @@ -64,6 +66,7 @@ namespace Bit.Core.IdentityServer _logger = logger; _currentContext = currentContext; _globalSettings = globalSettings; + _policyRepository = policyRepository; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request) @@ -112,9 +115,18 @@ namespace Bit.Core.IdentityServer twoFactorToken = null; } - var device = await SaveDeviceAsync(user, request); - await BuildSuccessResultAsync(user, context, device, twoFactorRequest && twoFactorRemember); - return; + if (await IsValidAuthTypeAsync(user, request.GrantType)) // Returns true if can finish validation process + { + var device = await SaveDeviceAsync(user, request); + await BuildSuccessResultAsync(user, context, device, twoFactorRequest && twoFactorRemember); + } + else + { + SetSsoResult(context, new Dictionary + {{ + "ErrorModel", new ErrorResponseModel("SSO authentication is required.") + }}); + } } protected abstract Task<(User, bool)> ValidateContextAsync(T context); @@ -229,6 +241,8 @@ namespace Bit.Core.IdentityServer } protected abstract void SetTwoFactorResult(T context, Dictionary customResponse); + + protected abstract void SetSsoResult(T context, Dictionary customResponse); protected abstract void SetSuccessResult(T context, User user, List claims, Dictionary customResponse); @@ -259,11 +273,63 @@ namespace Bit.Core.IdentityServer return new Tuple(individualRequired || firstEnabledOrg != null, firstEnabledOrg); } + private async Task IsValidAuthTypeAsync(User user, string grantType) + { + if (grantType == "authorization_code") + { + return true; // Already using SSO to authorize, finish successfully + } + + // Is user apart of any orgs? Use cache for initial checks. + var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) + .ToList(); + if (orgs.Any()) + { + // Get all org abilities + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + // Parse all user orgs that are enabled and have the ability to use sso + var ssoOrgs = orgs.Where(o => OrgCanUseSso(orgAbilities, o.Id)); + if (ssoOrgs.Any()) + { + // Parse users orgs and determine if require sso policy is enabled + var userOrgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); + foreach (var org in userOrgs) + { + if (!(org.Enabled && org.UseSso)) + { + continue; + } + + var orgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(org.Id, PolicyType.RequireSso); + if (orgPolicy != null && orgPolicy.Enabled) + { + var userType = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); + // Owners and Admins are exempt from this policy + if (userType != null + && userType.Type != OrganizationUserType.Owner + && userType.Type != OrganizationUserType.Admin) + { + return false; + } + } + } + } + } + + return true; // Default - continue validation process + } + private bool OrgUsing2fa(IDictionary orgAbilities, Guid orgId) { return orgAbilities != null && orgAbilities.ContainsKey(orgId) && orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa; } + + private bool OrgCanUseSso(IDictionary orgAbilities, Guid orgId) + { + return orgAbilities != null && orgAbilities.ContainsKey(orgId) && + orgAbilities[orgId].Enabled && orgAbilities[orgId].UseSso; + } private Device GetDeviceFromRequest(ValidatedRequest request) { diff --git a/src/Core/IdentityServer/CustomTokenRequestValidator.cs b/src/Core/IdentityServer/CustomTokenRequestValidator.cs index d12d220e0b..f5246d626c 100644 --- a/src/Core/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Core/IdentityServer/CustomTokenRequestValidator.cs @@ -31,10 +31,11 @@ namespace Bit.Core.IdentityServer IMailService mailService, ILogger logger, CurrentContext currentContext, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IPolicyRepository policyRepository) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, - applicationCacheService, mailService, logger, currentContext, globalSettings) + applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository) { _userManager = userManager; } @@ -78,6 +79,9 @@ namespace Bit.Core.IdentityServer context.Result.CustomResponse = customResponse; } + protected override void SetSsoResult(CustomTokenRequestValidationContext context, + Dictionary customResponse) => throw new System.NotImplementedException(); + protected override void SetErrorResult(CustomTokenRequestValidationContext context, Dictionary customResponse) { diff --git a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs index 1422d0d887..7c4c653258 100644 --- a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -31,10 +31,11 @@ namespace Bit.Core.IdentityServer IMailService mailService, ILogger logger, CurrentContext currentContext, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IPolicyRepository policyRepository) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, - applicationCacheService, mailService, logger, currentContext, globalSettings) + applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository) { _userManager = userManager; _userService = userService; @@ -77,6 +78,13 @@ namespace Bit.Core.IdentityServer customResponse); } + protected override void SetSsoResult(ResourceOwnerPasswordValidationContext context, + Dictionary customResponse) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.", + customResponse); + } + protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context, Dictionary customResponse) { diff --git a/src/Core/Models/Data/OrganizationAbility.cs b/src/Core/Models/Data/OrganizationAbility.cs index 4e8bc76896..9f2e1389b1 100644 --- a/src/Core/Models/Data/OrganizationAbility.cs +++ b/src/Core/Models/Data/OrganizationAbility.cs @@ -16,6 +16,7 @@ namespace Bit.Core.Models.Data organization.TwoFactorProviders != "{}"; UsersGetPremium = organization.UsersGetPremium; Enabled = organization.Enabled; + UseSso = organization.UseSso; } public Guid Id { get; set; } @@ -24,5 +25,6 @@ namespace Bit.Core.Models.Data public bool Using2fa { get; set; } public bool UsersGetPremium { get; set; } public bool Enabled { get; set; } + public bool UseSso { get; set; } } } diff --git a/src/Core/Repositories/EntityFramework/OrganizationRepository.cs b/src/Core/Repositories/EntityFramework/OrganizationRepository.cs index 5670ef8431..47bae92fef 100644 --- a/src/Core/Repositories/EntityFramework/OrganizationRepository.cs +++ b/src/Core/Repositories/EntityFramework/OrganizationRepository.cs @@ -99,6 +99,7 @@ namespace Bit.Core.Repositories.EntityFramework UseEvents = e.UseEvents, UsersGetPremium = e.UsersGetPremium, Using2fa = e.Use2fa && e.TwoFactorProviders != null, + UseSso = e.UseSso, }).ToListAsync(); } } diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index b751d85821..8c67749d07 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -548,4 +548,19 @@ Organization members who are already a part of another organization will be removed from this organization and will receive an email notifying them about the change. + + Single Sign-On Authentication + + + Require users to log in with the Enterprise Single Sign-On method. + + + Prerequisite + + + The Single Organization enterprise policy must be enabled before activating this policy. + + + Single Organization policy not enabled. + diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql index 1eea2777c9..a19c639266 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql @@ -14,6 +14,7 @@ BEGIN 0 END AS [Using2fa], [UsersGetPremium], + [UseSso], [Enabled] FROM [dbo].[Organization] diff --git a/util/Migrator/DbScripts/2020-10-08_00_DeleteOrgUserWithOrg b/util/Migrator/DbScripts/2020-10-08_00_DeleteOrgUserWithOrg.sql similarity index 100% rename from util/Migrator/DbScripts/2020-10-08_00_DeleteOrgUserWithOrg rename to util/Migrator/DbScripts/2020-10-08_00_DeleteOrgUserWithOrg.sql diff --git a/util/Migrator/DbScripts/2020-10-20_00_OrgReadAbilities.sql b/util/Migrator/DbScripts/2020-10-20_00_OrgReadAbilities.sql new file mode 100644 index 0000000000..8b261384fe --- /dev/null +++ b/util/Migrator/DbScripts/2020-10-20_00_OrgReadAbilities.sql @@ -0,0 +1,28 @@ +IF OBJECT_ID('[dbo].[Organization_ReadAbilities]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_ReadAbilities] +END +GO + +CREATE PROCEDURE [dbo].[Organization_ReadAbilities] +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [UseEvents], + [Use2fa], + CASE + WHEN [Use2fa] = 1 AND [TwoFactorProviders] IS NOT NULL AND [TwoFactorProviders] != '{}' THEN + 1 + ELSE + 0 + END AS [Using2fa], + [UsersGetPremium], + [UseSso], + [Enabled] + FROM + [dbo].[Organization] +END +GO