1
0
mirror of https://github.com/bitwarden/server synced 2025-12-30 15:14:02 +00:00

Merge branch 'main' into jmccannon/ac/pm-27131-auto-confirm-req

This commit is contained in:
Jared McCannon
2025-12-01 11:53:53 -06:00
41 changed files with 1706 additions and 153 deletions

View File

@@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
public virtual async Task StartAsync(CancellationToken cancellationToken)
{
// Wait 20 seconds to allow database to come online
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
var maxMigrationAttempts = 10;
for (var i = 1; i <= maxMigrationAttempts; i++)
@@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
{
_logger.LogError(e,
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
}
}
}

View File

@@ -33,7 +33,7 @@
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
</ItemGroup>

View File

@@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
@@ -16,19 +18,22 @@ public class SavePolicyCommand : ISavePolicyCommand
private readonly IReadOnlyDictionary<PolicyType, IPolicyValidator> _policyValidators;
private readonly TimeProvider _timeProvider;
private readonly IPostSavePolicySideEffect _postSavePolicySideEffect;
private readonly IPushNotificationService _pushNotificationService;
public SavePolicyCommand(IApplicationCacheService applicationCacheService,
IEventService eventService,
IPolicyRepository policyRepository,
IEnumerable<IPolicyValidator> policyValidators,
TimeProvider timeProvider,
IPostSavePolicySideEffect postSavePolicySideEffect)
IPostSavePolicySideEffect postSavePolicySideEffect,
IPushNotificationService pushNotificationService)
{
_applicationCacheService = applicationCacheService;
_eventService = eventService;
_policyRepository = policyRepository;
_timeProvider = timeProvider;
_postSavePolicySideEffect = postSavePolicySideEffect;
_pushNotificationService = pushNotificationService;
var policyValidatorsDict = new Dictionary<PolicyType, IPolicyValidator>();
foreach (var policyValidator in policyValidators)
@@ -75,6 +80,8 @@ public class SavePolicyCommand : ISavePolicyCommand
await _policyRepository.UpsertAsync(policy);
await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated);
await PushPolicyUpdateToClients(policy.OrganizationId, policy);
return policy;
}
@@ -152,4 +159,17 @@ public class SavePolicyCommand : ISavePolicyCommand
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type);
return (savedPoliciesDict, currentPolicy);
}
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => this._pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
{
Type = PushType.PolicyChanged,
Target = NotificationTarget.Organization,
TargetId = organizationId,
ExcludeCurrentContext = false,
Payload = new SyncPolicyPushNotification
{
Policy = policy,
OrganizationId = organizationId
}
});
}

View File

@@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Int
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
@@ -15,7 +17,8 @@ public class VNextSavePolicyCommand(
IPolicyRepository policyRepository,
IEnumerable<IPolicyUpdateEvent> policyUpdateEventHandlers,
TimeProvider timeProvider,
IPolicyEventHandlerFactory policyEventHandlerFactory)
IPolicyEventHandlerFactory policyEventHandlerFactory,
IPushNotificationService pushNotificationService)
: IVNextSavePolicyCommand
{
@@ -74,7 +77,7 @@ public class VNextSavePolicyCommand(
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await policyRepository.UpsertAsync(policy);
await PushPolicyUpdateToClients(policyUpdateRequest.OrganizationId, policy);
return policy;
}
@@ -192,4 +195,17 @@ public class VNextSavePolicyCommand(
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
return savedPoliciesDict;
}
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
{
Type = PushType.PolicyChanged,
Target = NotificationTarget.Organization,
TargetId = organizationId,
ExcludeCurrentContext = false,
Payload = new SyncPolicyPushNotification
{
Policy = policy,
OrganizationId = organizationId
}
});
}

View File

@@ -38,6 +38,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyValidator, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyValidator, AutomaticUserConfirmationPolicyEventHandler>();
}
[Obsolete("Use AddPolicyUpdateEvents instead.")]

View File

@@ -0,0 +1,23 @@
using Bit.Core.Entities;
namespace Bit.Core.Auth.Sso;
/// <summary>
/// Query to retrieve the SSO organization identifier that a user is a confirmed member of.
/// </summary>
public interface IUserSsoOrganizationIdentifierQuery
{
/// <summary>
/// Retrieves the SSO organization identifier for a confirmed organization user.
/// If there is more than one organization a User is associated with, we return null. If there are more than one
/// organization there is no way to know which organization the user wishes to authenticate with.
/// Owners and Admins who are not subject to the SSO required policy cannot utilize this flow, since they may have
/// multiple organizations with different SSO configurations.
/// </summary>
/// <param name="userId">The ID of the <see cref="User"/> to retrieve the SSO organization for. _Not_ an <see cref="OrganizationUser"/>.</param>
/// <returns>
/// The organization identifier if the user is a confirmed member of an organization with SSO configured,
/// otherwise null
/// </returns>
Task<string?> GetSsoOrganizationIdentifierAsync(Guid userId);
}

View File

@@ -0,0 +1,38 @@
using Bit.Core.Enums;
using Bit.Core.Repositories;
namespace Bit.Core.Auth.Sso;
/// <summary>
/// TODO : PM-28846 review data structures as they relate to this query
/// Query to retrieve the SSO organization identifier that a user is a confirmed member of.
/// </summary>
public class UserSsoOrganizationIdentifierQuery(
IOrganizationUserRepository _organizationUserRepository,
IOrganizationRepository _organizationRepository) : IUserSsoOrganizationIdentifierQuery
{
/// <inheritdoc />
public async Task<string?> GetSsoOrganizationIdentifierAsync(Guid userId)
{
// Get all confirmed organization memberships for the user
var organizationUsers = await _organizationUserRepository.GetManyByUserAsync(userId);
// we can only confidently return the correct SsoOrganizationIdentifier if there is exactly one Organization.
// The user must also be in the Confirmed status.
var confirmedOrgUsers = organizationUsers.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed);
if (confirmedOrgUsers.Count() != 1)
{
return null;
}
var confirmedOrgUser = confirmedOrgUsers.Single();
var organization = await _organizationRepository.GetByIdAsync(confirmedOrgUser.OrganizationId);
if (organization == null)
{
return null;
}
return organization.Identifier;
}
}

View File

@@ -1,5 +1,4 @@

using Bit.Core.Auth.Sso;
using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
@@ -29,6 +28,7 @@ public static class UserServiceCollectionExtensions
services.AddWebAuthnLoginCommands();
services.AddTdeOffboardingPasswordCommands();
services.AddTwoFactorQueries();
services.AddSsoQueries();
}
public static void AddDeviceTrustCommands(this IServiceCollection services)
@@ -69,4 +69,9 @@ public static class UserServiceCollectionExtensions
{
services.AddScoped<ITwoFactorIsEnabledQuery, TwoFactorIsEnabledQuery>();
}
private static void AddSsoQueries(this IServiceCollection services)
{
services.AddScoped<IUserSsoOrganizationIdentifierQuery, UserSsoOrganizationIdentifierQuery>();
}
}

View File

@@ -166,6 +166,7 @@ public static class FeatureFlagKeys
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
/* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
@@ -202,14 +203,11 @@ public static class FeatureFlagKeys
/* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
public const string Argon2Default = "argon2-default";
public const string UserkeyRotationV2 = "userkey-rotation-v2";
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
public const string UserSdkForDecryption = "use-sdk-for-decryption";
public const string EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation";
public const string PM17987_BlockType0 = "pm-17987-block-type-0";
public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings";
public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data";
public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2";

View File

@@ -38,10 +38,6 @@ public class CurrentContext(
public virtual List<CurrentContextProvider> Providers { get; set; }
public virtual Guid? InstallationId { get; set; }
public virtual Guid? OrganizationId { get; set; }
public virtual bool CloudflareWorkerProxied { get; set; }
public virtual bool IsBot { get; set; }
public virtual bool MaybeBot { get; set; }
public virtual int? BotScore { get; set; }
public virtual string ClientId { get; set; }
public virtual Version ClientVersion { get; set; }
public virtual bool ClientVersionIsPrerelease { get; set; }
@@ -70,27 +66,6 @@ public class CurrentContext(
DeviceType = dType;
}
if (!BotScore.HasValue && httpContext.Request.Headers.TryGetValue("X-Cf-Bot-Score", out var cfBotScore) &&
int.TryParse(cfBotScore, out var parsedBotScore))
{
BotScore = parsedBotScore;
}
if (httpContext.Request.Headers.TryGetValue("X-Cf-Worked-Proxied", out var cfWorkedProxied))
{
CloudflareWorkerProxied = cfWorkedProxied == "1";
}
if (httpContext.Request.Headers.TryGetValue("X-Cf-Is-Bot", out var cfIsBot))
{
IsBot = cfIsBot == "1";
}
if (httpContext.Request.Headers.TryGetValue("X-Cf-Maybe-Bot", out var cfMaybeBot))
{
MaybeBot = cfMaybeBot == "1";
}
if (httpContext.Request.Headers.TryGetValue("Bitwarden-Client-Version", out var bitWardenClientVersion) && Version.TryParse(bitWardenClientVersion, out var cVersion))
{
ClientVersion = cVersion;

View File

@@ -31,9 +31,6 @@ public interface ICurrentContext
Guid? InstallationId { get; set; }
Guid? OrganizationId { get; set; }
IdentityClientType IdentityClientType { get; set; }
bool IsBot { get; set; }
bool MaybeBot { get; set; }
int? BotScore { get; set; }
string ClientId { get; set; }
Version ClientVersion { get; set; }
bool ClientVersionIsPrerelease { get; set; }

View File

@@ -25,12 +25,12 @@
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.1.3" />
<PackageReference Include="AWSSDK.SQS" Version="4.0.1.5" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.1" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.21.2" />
<PackageReference Include="Azure.Storage.Queues" Version="12.19.1" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.20.1" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
<PackageReference Include="Azure.Storage.Queues" Version="12.24.0" />
<PackageReference Include="BitPay.Light" Version="1.0.1907" />
<PackageReference Include="DuoUniversal" Version="1.3.1" />
<PackageReference Include="DnsClient" Version="1.8.0" />
@@ -60,9 +60,9 @@
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.10.4" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="RabbitMQ.Client" Version="7.1.2" />
<PackageReference Include="ZiggyCreatures.FusionCache" Version="2.0.2" />
<PackageReference Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis" Version="2.0.2" />

View File

@@ -1,4 +1,5 @@
using Bit.Core.Enums;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Enums;
namespace Bit.Core.Models;
@@ -103,3 +104,9 @@ public class LogOutPushNotification
public Guid UserId { get; set; }
public PushNotificationLogOutReason? Reason { get; set; }
}
public class SyncPolicyPushNotification
{
public Guid OrganizationId { get; set; }
public required Policy Policy { get; set; }
}

View File

@@ -95,5 +95,8 @@ public enum PushType : byte
OrganizationBankAccountVerified = 23,
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))]
ProviderBankAccountVerified = 24
ProviderBankAccountVerified = 24,
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.SyncPolicyPushNotification))]
PolicyChanged = 25,
}

View File

@@ -0,0 +1,30 @@
namespace Bit.Identity.IdentityServer.RequestValidationConstants;
public static class CustomResponseConstants
{
public static class ResponseKeys
{
/// <summary>
/// Identifies the error model returned in the custom response when an error occurs.
/// </summary>
public static string ErrorModel => "ErrorModel";
/// <summary>
/// This Key is used when a user is in a single organization that requires SSO authentication. The identifier
/// is used by the client to speed the redirection to the correct IdP for the user's organization.
/// </summary>
public static string SsoOrganizationIdentifier => "SsoOrganizationIdentifier";
}
}
public static class SsoConstants
{
/// <summary>
/// These are messages and errors we return when SSO Validation is unsuccessful
/// </summary>
public static class RequestErrors
{
public static string SsoRequired => "sso_required";
public static string SsoRequiredDescription => "Sso authentication is required.";
public static string SsoTwoFactorRecoveryDescription => "Two-factor recovery has been performed. SSO authentication is required.";
}
}

View File

@@ -34,6 +34,7 @@ public abstract class BaseRequestValidator<T> where T : class
private readonly IEventService _eventService;
private readonly IDeviceValidator _deviceValidator;
private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
private readonly ISsoRequestValidator _ssoRequestValidator;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly ILogger _logger;
private readonly GlobalSettings _globalSettings;
@@ -43,7 +44,7 @@ public abstract class BaseRequestValidator<T> where T : class
protected ICurrentContext CurrentContext { get; }
protected IPolicyService PolicyService { get; }
protected IFeatureService FeatureService { get; }
protected IFeatureService _featureService { get; }
protected ISsoConfigRepository SsoConfigRepository { get; }
protected IUserService _userService { get; }
protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; }
@@ -56,6 +57,7 @@ public abstract class BaseRequestValidator<T> where T : class
IEventService eventService,
IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository,
ILogger logger,
ICurrentContext currentContext,
@@ -76,13 +78,14 @@ public abstract class BaseRequestValidator<T> where T : class
_eventService = eventService;
_deviceValidator = deviceValidator;
_twoFactorAuthenticationValidator = twoFactorAuthenticationValidator;
_ssoRequestValidator = ssoRequestValidator;
_organizationUserRepository = organizationUserRepository;
_logger = logger;
CurrentContext = currentContext;
_globalSettings = globalSettings;
PolicyService = policyService;
_userRepository = userRepository;
FeatureService = featureService;
_featureService = featureService;
SsoConfigRepository = ssoConfigRepository;
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
PolicyRequirementQuery = policyRequirementQuery;
@@ -94,7 +97,7 @@ public abstract class BaseRequestValidator<T> where T : class
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
CustomValidatorRequestContext validatorContext)
{
if (FeatureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers))
if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers))
{
var validators = DetermineValidationOrder(context, request, validatorContext);
var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators);
@@ -120,15 +123,29 @@ public abstract class BaseRequestValidator<T> where T : class
}
// 2. Decide if this user belongs to an organization that requires SSO.
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
if (validatorContext.SsoRequired)
// TODO: Clean up Feature Flag: Remove this if block: PM-28281
if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired))
{
SetSsoResult(context,
new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
return;
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
if (validatorContext.SsoRequired)
{
SetSsoResult(context,
new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
return;
}
}
else
{
var ssoValid = await _ssoRequestValidator.ValidateAsync(user, request, validatorContext);
if (!ssoValid)
{
// SSO is required
SetValidationErrorResult(context, validatorContext);
return;
}
}
// 3. Check if 2FA is required.
@@ -355,36 +372,51 @@ public abstract class BaseRequestValidator<T> where T : class
private async Task<bool> ValidateSsoAsync(T context, ValidatedTokenRequest request,
CustomValidatorRequestContext validatorContext)
{
validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType);
if (!validatorContext.SsoRequired)
// TODO: Clean up Feature Flag: Remove this if block: PM-28281
if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired))
{
return true;
}
validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType);
if (!validatorContext.SsoRequired)
{
return true;
}
// Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are
// presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and
// review their new recovery token if desired.
// SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery.
// As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been
// evaluated, and recovery will have been performed if requested.
// We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect
// to /login.
if (validatorContext.TwoFactorRequired &&
validatorContext.TwoFactorRecoveryRequested)
{
SetSsoResult(context, new Dictionary<string, object>
// Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are
// presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and
// review their new recovery token if desired.
// SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery.
// As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been
// evaluated, and recovery will have been performed if requested.
// We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect
// to /login.
if (validatorContext.TwoFactorRequired &&
validatorContext.TwoFactorRecoveryRequested)
{
SetSsoResult(context, new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") }
});
return false;
}
SetSsoResult(context,
new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
return false;
}
SetSsoResult(context,
new Dictionary<string, object>
else
{
var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext);
if (ssoValid)
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
return false;
return true;
}
SetValidationErrorResult(context, validatorContext);
return ssoValid;
}
}
/// <summary>
@@ -651,6 +683,7 @@ public abstract class BaseRequestValidator<T> where T : class
/// <param name="user">user trying to login</param>
/// <param name="grantType">magic string identifying the grant type requested</param>
/// <returns>true if sso required; false if not required or already in process</returns>
[Obsolete("This method is deprecated and will be removed in future versions, PM-28281. Please use the SsoRequestValidator scheme instead.")]
private async Task<bool> RequireSsoLoginAsync(User user, string grantType)
{
if (grantType == "authorization_code" || grantType == "client_credentials")
@@ -661,7 +694,7 @@ public abstract class BaseRequestValidator<T> where T : class
}
// Check if user belongs to any organization with an active SSO policy
var ssoRequired = FeatureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
? (await PolicyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(user.Id))
.SsoRequired
: await PolicyService.AnyPoliciesApplicableToUserAsync(
@@ -703,7 +736,7 @@ public abstract class BaseRequestValidator<T> where T : class
private async Task SendFailedTwoFactorEmail(User user, TwoFactorProviderType failedAttemptType)
{
if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail))
if (_featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail))
{
await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow,
CurrentContext.IpAddress);

View File

@@ -36,6 +36,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
IEventService eventService,
IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository,
ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext,
@@ -56,6 +57,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
eventService,
deviceValidator,
twoFactorAuthenticationValidator,
ssoRequestValidator,
organizationUserRepository,
logger,
currentContext,

View File

@@ -0,0 +1,19 @@
using Bit.Core.Entities;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators;
/// <summary>
/// Validates whether a user is required to authenticate via SSO based on organization policies.
/// </summary>
public interface ISsoRequestValidator
{
/// <summary>
/// Validates the SSO requirement for a user attempting to authenticate. Sets the error state in the <see cref="CustomValidatorRequestContext.CustomResponse"/> if SSO is required.
/// </summary>
/// <param name="user">The user attempting to authenticate.</param>
/// <param name="request">The token request containing grant type and other authentication details.</param>
/// <param name="context">The validator context to be updated with SSO requirement status and error results if applicable.</param>
/// <returns>true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow.</returns>
Task<bool> ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context);
}

View File

@@ -31,6 +31,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IEventService eventService,
IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository,
ILogger<ResourceOwnerPasswordValidator> logger,
ICurrentContext currentContext,
@@ -50,6 +51,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
eventService,
deviceValidator,
twoFactorAuthenticationValidator,
ssoRequestValidator,
organizationUserRepository,
logger,
currentContext,

View File

@@ -0,0 +1,126 @@
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Sso;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Services;
using Bit.Identity.IdentityServer.RequestValidationConstants;
using Duende.IdentityModel;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators;
/// <summary>
/// Validates whether a user is required to authenticate via SSO based on organization policies.
/// </summary>
public class SsoRequestValidator(
IPolicyService _policyService,
IFeatureService _featureService,
IUserSsoOrganizationIdentifierQuery _userSsoOrganizationIdentifierQuery,
IPolicyRequirementQuery _policyRequirementQuery) : ISsoRequestValidator
{
/// <summary>
/// Validates the SSO requirement for a user attempting to authenticate.
/// Sets context.SsoRequired to indicate whether SSO is required.
/// If SSO is required, sets the validation error result and custom response in the context.
/// </summary>
/// <param name="user">The user attempting to authenticate.</param>
/// <param name="request">The token request containing grant type and other authentication details.</param>
/// <param name="context">The validator context to be updated with SSO requirement status and error results if applicable.</param>
/// <returns>true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow.</returns>
public async Task<bool> ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context)
{
context.SsoRequired = await RequireSsoAuthenticationAsync(user, request.GrantType);
if (!context.SsoRequired)
{
return true;
}
// Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are
// presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and
// review their new recovery token if desired.
// SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery.
// As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been
// evaluated, and recovery will have been performed if requested.
// We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect
// to /login.
// If the feature flag RecoveryCodeSupportForSsoRequiredUsers is set to false then this code is unreachable since
// Two Factor validation occurs after SSO validation in that scenario.
if (context.TwoFactorRequired && context.TwoFactorRecoveryRequested)
{
await SetContextCustomResponseSsoErrorAsync(context, SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription);
return false;
}
await SetContextCustomResponseSsoErrorAsync(context, SsoConstants.RequestErrors.SsoRequiredDescription);
return false;
}
/// <summary>
/// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are
/// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement.
/// If the GrantType is authorization_code or client_credentials we know the user is trying to login
/// using the SSO flow so they are allowed to continue.
/// </summary>
/// <param name="user">user trying to login</param>
/// <param name="grantType">magic string identifying the grant type requested</param>
/// <returns>true if sso required; false if not required or already in process</returns>
private async Task<bool> RequireSsoAuthenticationAsync(User user, string grantType)
{
if (grantType == OidcConstants.GrantTypes.AuthorizationCode ||
grantType == OidcConstants.GrantTypes.ClientCredentials)
{
// SSO is not required for users already using SSO to authenticate which uses the authorization_code grant type,
// or logging-in via API key which is the client_credentials grant type.
// Allow user to continue request validation
return false;
}
// Check if user belongs to any organization with an active SSO policy
var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
? (await _policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(user.Id))
.SsoRequired
: await _policyService.AnyPoliciesApplicableToUserAsync(
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (ssoRequired)
{
return true;
}
// Default - SSO is not required
return false;
}
/// <summary>
/// Sets the customResponse in the context with the error result for the SSO validation failure.
/// </summary>
/// <param name="context">The validator context to update with error details.</param>
/// <param name="errorMessage">The error message to return to the client.</param>
private async Task SetContextCustomResponseSsoErrorAsync(CustomValidatorRequestContext context, string errorMessage)
{
var ssoOrganizationIdentifier = await _userSsoOrganizationIdentifierQuery.GetSsoOrganizationIdentifierAsync(context.User.Id);
context.ValidationErrorResult = new ValidationResult
{
IsError = true,
Error = OidcConstants.TokenErrors.InvalidGrant,
ErrorDescription = errorMessage
};
context.CustomResponse = new Dictionary<string, object>
{
{ CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(errorMessage) }
};
// Include organization identifier in the response if available
if (!string.IsNullOrEmpty(ssoOrganizationIdentifier))
{
context.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier] = ssoOrganizationIdentifier;
}
}
}

View File

@@ -38,6 +38,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IEventService eventService,
IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository,
ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext,
@@ -59,6 +60,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
eventService,
deviceValidator,
twoFactorAuthenticationValidator,
ssoRequestValidator,
organizationUserRepository,
logger,
currentContext,

View File

@@ -26,6 +26,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
services.AddTransient<IDeviceValidator, DeviceValidator>();
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
services.AddTransient<ISsoRequestValidator, SsoRequestValidator>();
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();

View File

@@ -231,9 +231,26 @@ public class HubHelpers
await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString())
.SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken);
break;
case PushType.PolicyChanged:
await policyChangedNotificationHandler(notificationJson, cancellationToken);
break;
default:
_logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type);
break;
}
}
private async Task policyChangedNotificationHandler(string notificationJson, CancellationToken cancellationToken)
{
var policyData = JsonSerializer.Deserialize<PushNotificationData<SyncPolicyPushNotification>>(notificationJson, _deserializerOptions);
if (policyData is null)
{
return;
}
await _hubContext.Clients
.Group(NotificationsHub.GetOrganizationGroup(policyData.Payload.OrganizationId))
.SendAsync(_receiveMessageMethod, policyData, cancellationToken);
}
}