mirror of
https://github.com/bitwarden/server
synced 2025-12-13 06:43:45 +00:00
fix(base-request-validator) [PM-21153] Recovery Code Not Functioning for SSO-required Users (#6481)
* chore(feature-flag-keys) [PM-21153]: Add feature flag key for BaseRequestValidator changes. * fix(base-request-validator) [PM-21153]: Add validation state model for composable validation scenarios. * fix(base-request-validator) [PM-21153]: Update BaseRequestValidator to allow validation scenarios to be composable. * fix(base-request-validator) [PM-21153]: Remove validation state object in favor of validator context, per team discussion. * feat(base-request-validator) [PM-21153]: Update tests to use issue feature flag, both execution paths. * fix(base-request-validator) [PM-21153]: Fix a null dictionary check. * chore(base-request-validator) [PM-21153]: Add unit tests around behavior addressed in this feature. * chore(base-request-validator) [PM-21153]: Update comments for clarity. * chore(base-request-validator-tests) [PM-21153]: Update verbiage for tests. * fix(base-request-validator) [PM-21153]: Update validators to no longer need completed scheme management, use 2FA flag for recovery scenarios. * fix(base-request-validator-tests) [PM-21153]: Customize CustomValidatorRequestContext fixture to allow for setting of request-specific flags as part of the request validation (not eagerly truthy).
This commit is contained in:
@@ -156,6 +156,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
|
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
|
||||||
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
|
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
|
||||||
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
|
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
|
||||||
|
public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required";
|
||||||
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
|
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
|
||||||
|
|
||||||
/* Autofill Team */
|
/* Autofill Team */
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public class CustomValidatorRequestContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TwoFactorRequired { get; set; } = false;
|
public bool TwoFactorRequired { get; set; } = false;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Whether the user has requested recovery of their 2FA methods using their one-time
|
||||||
|
/// recovery code.
|
||||||
|
/// </summary>
|
||||||
|
/// <seealso cref="Bit.Core.Auth.Enums.TwoFactorProviderType"/>
|
||||||
|
public bool TwoFactorRecoveryRequested { get; set; } = false;
|
||||||
|
/// <summary>
|
||||||
/// This communicates whether or not SSO is required for the user to authenticate.
|
/// This communicates whether or not SSO is required for the user to authenticate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SsoRequired { get; set; } = false;
|
public bool SsoRequired { get; set; } = false;
|
||||||
@@ -42,10 +48,13 @@ public class CustomValidatorRequestContext
|
|||||||
/// This will be null if the authentication request is successful.
|
/// This will be null if the authentication request is successful.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, object> CustomResponse { get; set; }
|
public Dictionary<string, object> CustomResponse { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A validated auth request
|
/// A validated auth request
|
||||||
/// <see cref="AuthRequest.IsValidForAuthentication"/>
|
/// <see cref="AuthRequest.IsValidForAuthentication"/>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AuthRequest ValidatedAuthRequest { get; set; }
|
public AuthRequest ValidatedAuthRequest { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the user has requested a Remember Me token for their current device.
|
||||||
|
/// </summary>
|
||||||
|
public bool RememberMeRequested { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
@@ -68,7 +69,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
IAuthRequestRepository authRequestRepository,
|
IAuthRequestRepository authRequestRepository,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IUserAccountKeysQuery userAccountKeysQuery
|
IUserAccountKeysQuery userAccountKeysQuery
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
@@ -93,125 +94,141 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
||||||
CustomValidatorRequestContext validatorContext)
|
CustomValidatorRequestContext validatorContext)
|
||||||
{
|
{
|
||||||
// 1. We need to check if the user's master password hash is correct.
|
if (FeatureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers))
|
||||||
var valid = await ValidateContextAsync(context, validatorContext);
|
|
||||||
var user = validatorContext.User;
|
|
||||||
if (!valid)
|
|
||||||
{
|
{
|
||||||
await UpdateFailedAuthDetailsAsync(user);
|
var validators = DetermineValidationOrder(context, request, validatorContext);
|
||||||
|
var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators);
|
||||||
await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
|
if (!allValidationSchemesSuccessful)
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Decide if this user belongs to an organization that requires SSO.
|
|
||||||
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
|
|
||||||
if (validatorContext.SsoRequired)
|
|
||||||
{
|
|
||||||
SetSsoResult(context,
|
|
||||||
new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check if 2FA is required.
|
|
||||||
(validatorContext.TwoFactorRequired, var twoFactorOrganization) =
|
|
||||||
await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
|
|
||||||
|
|
||||||
// This flag is used to determine if the user wants a rememberMe token sent when
|
|
||||||
// authentication is successful.
|
|
||||||
var returnRememberMeToken = false;
|
|
||||||
|
|
||||||
if (validatorContext.TwoFactorRequired)
|
|
||||||
{
|
|
||||||
var twoFactorToken = request.Raw["TwoFactorToken"];
|
|
||||||
var twoFactorProvider = request.Raw["TwoFactorProvider"];
|
|
||||||
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
|
|
||||||
!string.IsNullOrWhiteSpace(twoFactorProvider);
|
|
||||||
|
|
||||||
// 3a. Response for 2FA required and not provided state.
|
|
||||||
if (!validTwoFactorRequest ||
|
|
||||||
!Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
|
|
||||||
{
|
{
|
||||||
var resultDict = await _twoFactorAuthenticationValidator
|
// Each validation task is responsible for setting its own non-success status, if applicable.
|
||||||
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
|
return;
|
||||||
if (resultDict == null)
|
}
|
||||||
|
await BuildSuccessResultAsync(validatorContext.User, context, validatorContext.Device,
|
||||||
|
validatorContext.RememberMeRequested);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 1. We need to check if the user's master password hash is correct.
|
||||||
|
var valid = await ValidateContextAsync(context, validatorContext);
|
||||||
|
var user = validatorContext.User;
|
||||||
|
if (!valid)
|
||||||
|
{
|
||||||
|
await UpdateFailedAuthDetailsAsync(user);
|
||||||
|
|
||||||
|
await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decide if this user belongs to an organization that requires SSO.
|
||||||
|
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
|
||||||
|
if (validatorContext.SsoRequired)
|
||||||
|
{
|
||||||
|
SetSsoResult(context,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if 2FA is required.
|
||||||
|
(validatorContext.TwoFactorRequired, var twoFactorOrganization) =
|
||||||
|
await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
|
||||||
|
|
||||||
|
// This flag is used to determine if the user wants a rememberMe token sent when
|
||||||
|
// authentication is successful.
|
||||||
|
var returnRememberMeToken = false;
|
||||||
|
|
||||||
|
if (validatorContext.TwoFactorRequired)
|
||||||
|
{
|
||||||
|
var twoFactorToken = request.Raw["TwoFactorToken"];
|
||||||
|
var twoFactorProvider = request.Raw["TwoFactorProvider"];
|
||||||
|
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
|
||||||
|
!string.IsNullOrWhiteSpace(twoFactorProvider);
|
||||||
|
|
||||||
|
// 3a. Response for 2FA required and not provided state.
|
||||||
|
if (!validTwoFactorRequest ||
|
||||||
|
!Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
|
||||||
{
|
{
|
||||||
await BuildErrorResultAsync("No two-step providers enabled.", false, context, user);
|
var resultDict = await _twoFactorAuthenticationValidator
|
||||||
|
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
|
||||||
|
if (resultDict == null)
|
||||||
|
{
|
||||||
|
await BuildErrorResultAsync("No two-step providers enabled.", false, context, user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include Master Password Policy in 2FA response.
|
||||||
|
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
|
||||||
|
SetTwoFactorResult(context, resultDict);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include Master Password Policy in 2FA response.
|
var twoFactorTokenValid =
|
||||||
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
|
await _twoFactorAuthenticationValidator
|
||||||
SetTwoFactorResult(context, resultDict);
|
.VerifyTwoFactorAsync(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
|
||||||
|
|
||||||
|
// 3b. Response for 2FA required but request is not valid or remember token expired state.
|
||||||
|
if (!twoFactorTokenValid)
|
||||||
|
{
|
||||||
|
// The remember me token has expired.
|
||||||
|
if (twoFactorProviderType == TwoFactorProviderType.Remember)
|
||||||
|
{
|
||||||
|
var resultDict = await _twoFactorAuthenticationValidator
|
||||||
|
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
|
||||||
|
|
||||||
|
// Include Master Password Policy in 2FA response
|
||||||
|
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
|
||||||
|
SetTwoFactorResult(context, resultDict);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SendFailedTwoFactorEmail(user, twoFactorProviderType);
|
||||||
|
await UpdateFailedAuthDetailsAsync(user);
|
||||||
|
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3c. When the 2FA authentication is successful, we can check if the user wants a
|
||||||
|
// rememberMe token.
|
||||||
|
var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1";
|
||||||
|
// Check if the user wants a rememberMe token.
|
||||||
|
if (twoFactorRemember
|
||||||
|
// if the 2FA auth was rememberMe do not send another token.
|
||||||
|
&& twoFactorProviderType != TwoFactorProviderType.Remember)
|
||||||
|
{
|
||||||
|
returnRememberMeToken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check if the user is logging in from a new device.
|
||||||
|
var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
|
||||||
|
if (!deviceValid)
|
||||||
|
{
|
||||||
|
SetValidationErrorResult(context, validatorContext);
|
||||||
|
await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var twoFactorTokenValid =
|
// 5. Force legacy users to the web for migration.
|
||||||
await _twoFactorAuthenticationValidator
|
if (UserService.IsLegacyUser(user) && request.ClientId != "web")
|
||||||
.VerifyTwoFactorAsync(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
|
|
||||||
|
|
||||||
// 3b. Response for 2FA required but request is not valid or remember token expired state.
|
|
||||||
if (!twoFactorTokenValid)
|
|
||||||
{
|
{
|
||||||
// The remember me token has expired.
|
await FailAuthForLegacyUserAsync(user, context);
|
||||||
if (twoFactorProviderType == TwoFactorProviderType.Remember)
|
|
||||||
{
|
|
||||||
var resultDict = await _twoFactorAuthenticationValidator
|
|
||||||
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
|
|
||||||
|
|
||||||
// Include Master Password Policy in 2FA response
|
|
||||||
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
|
|
||||||
SetTwoFactorResult(context, resultDict);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await SendFailedTwoFactorEmail(user, twoFactorProviderType);
|
|
||||||
await UpdateFailedAuthDetailsAsync(user);
|
|
||||||
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3c. When the 2FA authentication is successful, we can check if the user wants a
|
// TODO: PM-24324 - This should be its own validator at some point.
|
||||||
// rememberMe token.
|
// 6. Auth request handling
|
||||||
var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1";
|
if (validatorContext.ValidatedAuthRequest != null)
|
||||||
// Check if the user wants a rememberMe token.
|
|
||||||
if (twoFactorRemember
|
|
||||||
// if the 2FA auth was rememberMe do not send another token.
|
|
||||||
&& twoFactorProviderType != TwoFactorProviderType.Remember)
|
|
||||||
{
|
{
|
||||||
returnRememberMeToken = true;
|
validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow;
|
||||||
|
await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Check if the user is logging in from a new device.
|
await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
|
||||||
var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
|
|
||||||
if (!deviceValid)
|
|
||||||
{
|
|
||||||
SetValidationErrorResult(context, validatorContext);
|
|
||||||
await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Force legacy users to the web for migration.
|
|
||||||
if (UserService.IsLegacyUser(user) && request.ClientId != "web")
|
|
||||||
{
|
|
||||||
await FailAuthForLegacyUserAsync(user, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: PM-24324 - This should be its own validator at some point.
|
|
||||||
// 6. Auth request handling
|
|
||||||
if (validatorContext.ValidatedAuthRequest != null)
|
|
||||||
{
|
|
||||||
validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow;
|
|
||||||
await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task FailAuthForLegacyUserAsync(User user, T context)
|
protected async Task FailAuthForLegacyUserAsync(User user, T context)
|
||||||
@@ -223,6 +240,302 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
|
|
||||||
protected abstract Task<bool> ValidateContextAsync(T context, CustomValidatorRequestContext validatorContext);
|
protected abstract Task<bool> ValidateContextAsync(T context, CustomValidatorRequestContext validatorContext);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composer for validation schemes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The current request context.</param>
|
||||||
|
/// <param name="request"><see cref="Duende.IdentityServer.Validation.ValidatedTokenRequest" /></param>
|
||||||
|
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
|
||||||
|
/// <returns>A composed array of validation scheme delegates to evaluate in order.</returns>
|
||||||
|
private Func<Task<bool>>[] DetermineValidationOrder(T context, ValidatedTokenRequest request,
|
||||||
|
CustomValidatorRequestContext validatorContext)
|
||||||
|
{
|
||||||
|
if (RecoveryCodeRequestForSsoRequiredUserScenario())
|
||||||
|
{
|
||||||
|
// Support valid requests to recover 2FA (with account code) for users who require SSO
|
||||||
|
// by organization membership.
|
||||||
|
// This requires an evaluation of 2FA validity in front of SSO, and an opportunity for the 2FA
|
||||||
|
// validation to perform the recovery as part of scheme validation based on the request.
|
||||||
|
return
|
||||||
|
[
|
||||||
|
() => ValidateMasterPasswordAsync(context, validatorContext),
|
||||||
|
() => ValidateTwoFactorAsync(context, request, validatorContext),
|
||||||
|
() => ValidateSsoAsync(context, request, validatorContext),
|
||||||
|
() => ValidateNewDeviceAsync(context, request, validatorContext),
|
||||||
|
() => ValidateLegacyMigrationAsync(context, request, validatorContext),
|
||||||
|
() => ValidateAuthRequestAsync(validatorContext)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// The typical validation scenario.
|
||||||
|
return
|
||||||
|
[
|
||||||
|
() => ValidateMasterPasswordAsync(context, validatorContext),
|
||||||
|
() => ValidateSsoAsync(context, request, validatorContext),
|
||||||
|
() => ValidateTwoFactorAsync(context, request, validatorContext),
|
||||||
|
() => ValidateNewDeviceAsync(context, request, validatorContext),
|
||||||
|
() => ValidateLegacyMigrationAsync(context, request, validatorContext),
|
||||||
|
() => ValidateAuthRequestAsync(validatorContext)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RecoveryCodeRequestForSsoRequiredUserScenario()
|
||||||
|
{
|
||||||
|
var twoFactorProvider = request.Raw["TwoFactorProvider"];
|
||||||
|
var twoFactorToken = request.Raw["TwoFactorToken"];
|
||||||
|
|
||||||
|
// Both provider and token must be present;
|
||||||
|
// Validity of the token for a given provider will be evaluated by the TwoFactorAuthenticationValidator.
|
||||||
|
if (string.IsNullOrWhiteSpace(twoFactorProvider) || string.IsNullOrWhiteSpace(twoFactorToken))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(twoFactorProvider, out var providerValue))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerValue == (int)TwoFactorProviderType.RecoveryCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes the validation schemes sequentially.
|
||||||
|
/// Each validator is responsible for setting error context responses on failure and adding itself to the
|
||||||
|
/// validatorContext's CompletedValidationSchemes (only) on success.
|
||||||
|
/// Failure of any scheme to validate will short-circuit the collection, causing the validation error to be
|
||||||
|
/// returned and further schemes to not be evaluated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="validators">The collection of validation schemes as composed in <see cref="DetermineValidationOrder" /></param>
|
||||||
|
/// <returns>true if all schemes validated successfully, false if any failed.</returns>
|
||||||
|
private static async Task<bool> ProcessValidatorsAsync(params Func<Task<bool>>[] validators)
|
||||||
|
{
|
||||||
|
foreach (var validator in validators)
|
||||||
|
{
|
||||||
|
if (!await validator())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the user's Master Password hash.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The current request context.</param>
|
||||||
|
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
|
||||||
|
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
|
||||||
|
private async Task<bool> ValidateMasterPasswordAsync(T context, CustomValidatorRequestContext validatorContext)
|
||||||
|
{
|
||||||
|
var valid = await ValidateContextAsync(context, validatorContext);
|
||||||
|
var user = validatorContext.User;
|
||||||
|
if (valid)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpdateFailedAuthDetailsAsync(user);
|
||||||
|
|
||||||
|
await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the user's organization-enforced Single Sign-on (SSO) requirement.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The current request context.</param>
|
||||||
|
/// <param name="request"><see cref="Duende.IdentityServer.Validation.ValidatedTokenRequest" /></param>
|
||||||
|
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
|
||||||
|
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
|
||||||
|
/// <seealso cref="DetermineValidationOrder" />
|
||||||
|
private async Task<bool> ValidateSsoAsync(T context, ValidatedTokenRequest request,
|
||||||
|
CustomValidatorRequestContext validatorContext)
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
{
|
||||||
|
{ "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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the user's Multi-Factor Authentication (2FA) scheme.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The current request context.</param>
|
||||||
|
/// <param name="request"><see cref="Duende.IdentityServer.Validation.ValidatedTokenRequest" /></param>
|
||||||
|
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
|
||||||
|
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
|
||||||
|
private async Task<bool> ValidateTwoFactorAsync(T context, ValidatedTokenRequest request,
|
||||||
|
CustomValidatorRequestContext validatorContext)
|
||||||
|
{
|
||||||
|
(validatorContext.TwoFactorRequired, var twoFactorOrganization) =
|
||||||
|
await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(validatorContext.User, request);
|
||||||
|
|
||||||
|
if (!validatorContext.TwoFactorRequired)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var twoFactorToken = request.Raw["TwoFactorToken"];
|
||||||
|
var twoFactorProvider = request.Raw["TwoFactorProvider"];
|
||||||
|
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
|
||||||
|
!string.IsNullOrWhiteSpace(twoFactorProvider);
|
||||||
|
|
||||||
|
// 3a. Response for 2FA required and not provided state.
|
||||||
|
if (!validTwoFactorRequest ||
|
||||||
|
!Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
|
||||||
|
{
|
||||||
|
var resultDict = await _twoFactorAuthenticationValidator
|
||||||
|
.BuildTwoFactorResultAsync(validatorContext.User, twoFactorOrganization);
|
||||||
|
if (resultDict == null)
|
||||||
|
{
|
||||||
|
await BuildErrorResultAsync("No two-step providers enabled.", false, context, validatorContext.User);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include Master Password Policy in 2FA response.
|
||||||
|
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(validatorContext.User));
|
||||||
|
SetTwoFactorResult(context, resultDict);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var twoFactorTokenValid =
|
||||||
|
await _twoFactorAuthenticationValidator
|
||||||
|
.VerifyTwoFactorAsync(validatorContext.User, twoFactorOrganization, twoFactorProviderType,
|
||||||
|
twoFactorToken);
|
||||||
|
|
||||||
|
// 3b. Response for 2FA required but request is not valid or remember token expired state.
|
||||||
|
if (!twoFactorTokenValid)
|
||||||
|
{
|
||||||
|
// The remember me token has expired.
|
||||||
|
if (twoFactorProviderType == TwoFactorProviderType.Remember)
|
||||||
|
{
|
||||||
|
var resultDict = await _twoFactorAuthenticationValidator
|
||||||
|
.BuildTwoFactorResultAsync(validatorContext.User, twoFactorOrganization);
|
||||||
|
|
||||||
|
// Include Master Password Policy in 2FA response
|
||||||
|
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(validatorContext.User));
|
||||||
|
SetTwoFactorResult(context, resultDict);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SendFailedTwoFactorEmail(validatorContext.User, twoFactorProviderType);
|
||||||
|
await UpdateFailedAuthDetailsAsync(validatorContext.User);
|
||||||
|
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context,
|
||||||
|
validatorContext.User);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3c. Given a valid token and a successful two-factor verification, if the provider type is Recovery Code,
|
||||||
|
// recovery will have been performed as part of 2FA validation. This will be relevant for, e.g., SSO users
|
||||||
|
// who are requesting recovery, but who will still need to log in after 2FA recovery.
|
||||||
|
if (twoFactorProviderType == TwoFactorProviderType.RecoveryCode)
|
||||||
|
{
|
||||||
|
validatorContext.TwoFactorRecoveryRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3d. When the 2FA authentication is successful, we can check if the user wants a
|
||||||
|
// rememberMe token.
|
||||||
|
var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1";
|
||||||
|
// Check if the user wants a rememberMe token.
|
||||||
|
if (twoFactorRemember
|
||||||
|
// if the 2FA auth was rememberMe do not send another token.
|
||||||
|
&& twoFactorProviderType != TwoFactorProviderType.Remember)
|
||||||
|
{
|
||||||
|
validatorContext.RememberMeRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates whether the user is logging in from a known device.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The current request context.</param>
|
||||||
|
/// <param name="request"><see cref="Duende.IdentityServer.Validation.ValidatedTokenRequest" /></param>
|
||||||
|
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
|
||||||
|
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
|
||||||
|
private async Task<bool> ValidateNewDeviceAsync(T context, ValidatedTokenRequest request,
|
||||||
|
CustomValidatorRequestContext validatorContext)
|
||||||
|
{
|
||||||
|
var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
|
||||||
|
if (deviceValid)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetValidationErrorResult(context, validatorContext);
|
||||||
|
await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates whether the user should be denied access on a given non-Web client and sent to the Web client
|
||||||
|
/// for Legacy migration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The current request context.</param>
|
||||||
|
/// <param name="request"><see cref="Duende.IdentityServer.Validation.ValidatedTokenRequest" /></param>
|
||||||
|
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
|
||||||
|
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
|
||||||
|
private async Task<bool> ValidateLegacyMigrationAsync(T context, ValidatedTokenRequest request,
|
||||||
|
CustomValidatorRequestContext validatorContext)
|
||||||
|
{
|
||||||
|
if (!UserService.IsLegacyUser(validatorContext.User) || request.ClientId == "web")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await FailAuthForLegacyUserAsync(validatorContext.User, context);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates and updates the auth request's timestamp.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
|
||||||
|
/// <returns>true on evaluation and/or completed update of the AuthRequest.</returns>
|
||||||
|
private async Task<bool> ValidateAuthRequestAsync(CustomValidatorRequestContext validatorContext)
|
||||||
|
{
|
||||||
|
// TODO: PM-24324 - This should be its own validator at some point.
|
||||||
|
if (validatorContext.ValidatedAuthRequest != null)
|
||||||
|
{
|
||||||
|
validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow;
|
||||||
|
await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Responsible for building the response to the client when the user has successfully authenticated.
|
/// Responsible for building the response to the client when the user has successfully authenticated.
|
||||||
@@ -256,7 +569,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
/// <param name="user">used to associate the failed login with a user</param>
|
/// <param name="user">used to associate the failed login with a user</param>
|
||||||
/// <returns>void</returns>
|
/// <returns>void</returns>
|
||||||
[Obsolete("Consider using SetValidationErrorResult to set the validation result, and LogFailedLoginEvent " +
|
[Obsolete("Consider using SetValidationErrorResult to set the validation result, and LogFailedLoginEvent " +
|
||||||
"to log the failure.")]
|
"to log the failure.")]
|
||||||
protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user)
|
protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user)
|
||||||
{
|
{
|
||||||
if (user != null)
|
if (user != null)
|
||||||
@@ -268,7 +581,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
if (_globalSettings.SelfHosted)
|
if (_globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(Constants.BypassFiltersEventId,
|
_logger.LogWarning(Constants.BypassFiltersEventId,
|
||||||
"Failed login attempt. Is2FARequest: {Is2FARequest} IpAddress: {IpAddress}", twoFactorRequest, CurrentContext.IpAddress);
|
"Failed login attempt. Is2FARequest: {Is2FARequest} IpAddress: {IpAddress}", twoFactorRequest,
|
||||||
|
CurrentContext.IpAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(2000); // Delay for brute force.
|
await Task.Delay(2000); // Delay for brute force.
|
||||||
@@ -292,21 +606,26 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
formattedMessage = string.Format("Failed login attempt. {0}", $" {CurrentContext.IpAddress}");
|
formattedMessage = string.Format("Failed login attempt. {0}", $" {CurrentContext.IpAddress}");
|
||||||
break;
|
break;
|
||||||
case EventType.User_FailedLogIn2fa:
|
case EventType.User_FailedLogIn2fa:
|
||||||
formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}", $" {CurrentContext.IpAddress}");
|
formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}",
|
||||||
|
$" {CurrentContext.IpAddress}");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
formattedMessage = "Failed login attempt.";
|
formattedMessage = "Failed login attempt.";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning(Constants.BypassFiltersEventId, "{FailedLoginMessage}", formattedMessage);
|
_logger.LogWarning(Constants.BypassFiltersEventId, "{FailedLoginMessage}", formattedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(2000); // Delay for brute force.
|
await Task.Delay(2000); // Delay for brute force.
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||||
protected abstract void SetTwoFactorResult(T context, Dictionary<string, object> customResponse);
|
protected abstract void SetTwoFactorResult(T context, Dictionary<string, object> customResponse);
|
||||||
|
|
||||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||||
protected abstract void SetSsoResult(T context, Dictionary<string, object> customResponse);
|
protected abstract void SetSsoResult(T context, Dictionary<string, object> customResponse);
|
||||||
|
|
||||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||||
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
|
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
|
||||||
|
|
||||||
@@ -317,6 +636,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
/// <param name="context">The current grant or token context</param>
|
/// <param name="context">The current grant or token context</param>
|
||||||
/// <param name="requestContext">The modified request context containing material used to build the response object</param>
|
/// <param name="requestContext">The modified request context containing material used to build the response object</param>
|
||||||
protected abstract void SetValidationErrorResult(T context, CustomValidatorRequestContext requestContext);
|
protected abstract void SetValidationErrorResult(T context, CustomValidatorRequestContext requestContext);
|
||||||
|
|
||||||
protected abstract Task SetSuccessResult(T context, User user, List<Claim> claims,
|
protected abstract Task SetSuccessResult(T context, User user, List<Claim> claims,
|
||||||
Dictionary<string, object> customResponse);
|
Dictionary<string, object> customResponse);
|
||||||
|
|
||||||
@@ -343,7 +663,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
// Check if user belongs to any organization with an active SSO policy
|
// 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))
|
? (await PolicyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(user.Id))
|
||||||
.SsoRequired
|
.SsoRequired
|
||||||
: await PolicyService.AnyPoliciesApplicableToUserAsync(
|
: await PolicyService.AnyPoliciesApplicableToUserAsync(
|
||||||
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||||
if (ssoRequired)
|
if (ssoRequired)
|
||||||
@@ -385,7 +705,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
{
|
{
|
||||||
if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail))
|
if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail))
|
||||||
{
|
{
|
||||||
await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, CurrentContext.IpAddress);
|
await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow,
|
||||||
|
CurrentContext.IpAddress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,16 +737,14 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
// We need this because we check for changes in the stamp to determine if we need to invalidate token refresh requests,
|
// We need this because we check for changes in the stamp to determine if we need to invalidate token refresh requests,
|
||||||
// in the `ProfileService.IsActiveAsync` method.
|
// in the `ProfileService.IsActiveAsync` method.
|
||||||
// If we don't store the security stamp in the persisted grant, we won't have the previous value to compare against.
|
// If we don't store the security stamp in the persisted grant, we won't have the previous value to compare against.
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim> { new Claim(Claims.SecurityStamp, user.SecurityStamp) };
|
||||||
{
|
|
||||||
new Claim(Claims.SecurityStamp, user.SecurityStamp)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (device != null)
|
if (device != null)
|
||||||
{
|
{
|
||||||
claims.Add(new Claim(Claims.Device, device.Identifier));
|
claims.Add(new Claim(Claims.Device, device.Identifier));
|
||||||
claims.Add(new Claim(Claims.DeviceType, device.Type.ToString()));
|
claims.Add(new Claim(Claims.DeviceType, device.Type.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return claims;
|
return claims;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +756,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
/// <param name="context">The current request context.</param>
|
/// <param name="context">The current request context.</param>
|
||||||
/// <param name="device">The device used for authentication.</param>
|
/// <param name="device">The device used for authentication.</param>
|
||||||
/// <param name="sendRememberToken">Whether to send a 2FA remember token.</param>
|
/// <param name="sendRememberToken">Whether to send a 2FA remember token.</param>
|
||||||
private async Task<Dictionary<string, object>> BuildCustomResponse(User user, T context, Device device, bool sendRememberToken)
|
private async Task<Dictionary<string, object>> BuildCustomResponse(User user, T context, Device device,
|
||||||
|
bool sendRememberToken)
|
||||||
{
|
{
|
||||||
var customResponse = new Dictionary<string, object>();
|
var customResponse = new Dictionary<string, object>();
|
||||||
if (!string.IsNullOrWhiteSpace(user.PrivateKey))
|
if (!string.IsNullOrWhiteSpace(user.PrivateKey))
|
||||||
@@ -459,7 +779,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
customResponse.Add("KdfIterations", user.KdfIterations);
|
customResponse.Add("KdfIterations", user.KdfIterations);
|
||||||
customResponse.Add("KdfMemory", user.KdfMemory);
|
customResponse.Add("KdfMemory", user.KdfMemory);
|
||||||
customResponse.Add("KdfParallelism", user.KdfParallelism);
|
customResponse.Add("KdfParallelism", user.KdfParallelism);
|
||||||
customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context)));
|
customResponse.Add("UserDecryptionOptions",
|
||||||
|
await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context)));
|
||||||
|
|
||||||
if (sendRememberToken)
|
if (sendRememberToken)
|
||||||
{
|
{
|
||||||
@@ -467,6 +788,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember));
|
CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember));
|
||||||
customResponse.Add("TwoFactorToken", token);
|
customResponse.Add("TwoFactorToken", token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return customResponse;
|
return customResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,7 +796,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
|
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<UserDecryptionOptions> CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject)
|
private async Task<UserDecryptionOptions> CreateUserDecryptionOptionsAsync(User user, Device device,
|
||||||
|
ClaimsPrincipal subject)
|
||||||
{
|
{
|
||||||
var ssoConfig = await GetSsoConfigurationDataAsync(subject);
|
var ssoConfig = await GetSsoConfigurationDataAsync(subject);
|
||||||
return await UserDecryptionOptionsBuilder
|
return await UserDecryptionOptionsBuilder
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using AutoFixture;
|
using AutoFixture;
|
||||||
using AutoFixture.Xunit2;
|
using AutoFixture.Xunit2;
|
||||||
|
using Bit.Identity.IdentityServer;
|
||||||
using Duende.IdentityServer.Validation;
|
using Duende.IdentityServer.Validation;
|
||||||
|
|
||||||
namespace Bit.Identity.Test.AutoFixture;
|
namespace Bit.Identity.Test.AutoFixture;
|
||||||
@@ -8,7 +9,8 @@ namespace Bit.Identity.Test.AutoFixture;
|
|||||||
internal class ValidatedTokenRequestCustomization : ICustomization
|
internal class ValidatedTokenRequestCustomization : ICustomization
|
||||||
{
|
{
|
||||||
public ValidatedTokenRequestCustomization()
|
public ValidatedTokenRequestCustomization()
|
||||||
{ }
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void Customize(IFixture fixture)
|
public void Customize(IFixture fixture)
|
||||||
{
|
{
|
||||||
@@ -22,10 +24,45 @@ internal class ValidatedTokenRequestCustomization : ICustomization
|
|||||||
public class ValidatedTokenRequestAttribute : CustomizeAttribute
|
public class ValidatedTokenRequestAttribute : CustomizeAttribute
|
||||||
{
|
{
|
||||||
public ValidatedTokenRequestAttribute()
|
public ValidatedTokenRequestAttribute()
|
||||||
{ }
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public override ICustomization GetCustomization(ParameterInfo parameter)
|
public override ICustomization GetCustomization(ParameterInfo parameter)
|
||||||
{
|
{
|
||||||
return new ValidatedTokenRequestCustomization();
|
return new ValidatedTokenRequestCustomization();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class CustomValidatorRequestContextCustomization : ICustomization
|
||||||
|
{
|
||||||
|
public CustomValidatorRequestContextCustomization()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specific context members like <see cref="CustomValidatorRequestContext.RememberMeRequested" />,
|
||||||
|
/// <see cref="CustomValidatorRequestContext.TwoFactorRecoveryRequested"/>, and
|
||||||
|
/// <see cref="CustomValidatorRequestContext.SsoRequired" /> should initialize false,
|
||||||
|
/// and are made truthy in context upon evaluation of a request. Do not allow AutoFixture to eagerly make these
|
||||||
|
/// truthy; that is the responsibility of the <see cref="Bit.Identity.IdentityServer.RequestValidators.BaseRequestValidator{T}" />
|
||||||
|
/// </summary>
|
||||||
|
public void Customize(IFixture fixture)
|
||||||
|
{
|
||||||
|
fixture.Customize<CustomValidatorRequestContext>(composer => composer
|
||||||
|
.With(o => o.RememberMeRequested, false)
|
||||||
|
.With(o => o.TwoFactorRecoveryRequested, false)
|
||||||
|
.With(o => o.SsoRequired, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CustomValidatorRequestContextAttribute : CustomizeAttribute
|
||||||
|
{
|
||||||
|
public CustomValidatorRequestContextAttribute()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ICustomization GetCustomization(ParameterInfo parameter)
|
||||||
|
{
|
||||||
|
return new CustomValidatorRequestContextCustomization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,19 +100,30 @@ public class BaseRequestValidatorTests
|
|||||||
_userAccountKeysQuery);
|
_userAccountKeysQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(bool recoveryCodeSupportEnabled)
|
||||||
|
{
|
||||||
|
_featureService
|
||||||
|
.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)
|
||||||
|
.Returns(recoveryCodeSupportEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
/* Logic path
|
/* Logic path
|
||||||
* ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
|
* ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
|
||||||
* |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|
* |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|
||||||
* (self hosted) |-> _logger.LogWarning()
|
* (self hosted) |-> _logger.LogWarning()
|
||||||
* |-> SetErrorResult
|
* |-> SetErrorResult
|
||||||
*/
|
*/
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning(
|
public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
_globalSettings.SelfHosted = true;
|
_globalSettings.SelfHosted = true;
|
||||||
_sut.isValid = false;
|
_sut.isValid = false;
|
||||||
@@ -122,18 +133,23 @@ public class BaseRequestValidatorTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var logs = _logger.Collector.GetSnapshot(true);
|
var logs = _logger.Collector.GetSnapshot(true);
|
||||||
Assert.Contains(logs, l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: ");
|
Assert.Contains(logs,
|
||||||
|
l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: ");
|
||||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||||
Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
|
Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_DeviceNotValidated_ShouldLogError(
|
public async Task ValidateAsync_DeviceNotValidated_ShouldLogError(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
// 1 -> to pass
|
// 1 -> to pass
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
@@ -141,14 +157,15 @@ public class BaseRequestValidatorTests
|
|||||||
// 2 -> will result to false with no extra configuration
|
// 2 -> will result to false with no extra configuration
|
||||||
// 3 -> set two factor to be false
|
// 3 -> set two factor to be false
|
||||||
_twoFactorAuthenticationValidator
|
_twoFactorAuthenticationValidator
|
||||||
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
||||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||||
|
|
||||||
// 4 -> set up device validator to fail
|
// 4 -> set up device validator to fail
|
||||||
requestContext.KnownDevice = false;
|
requestContext.KnownDevice = false;
|
||||||
tokenRequest.GrantType = "password";
|
tokenRequest.GrantType = "password";
|
||||||
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
_deviceValidator
|
||||||
.Returns(Task.FromResult(false));
|
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
||||||
|
.Returns(Task.FromResult(false));
|
||||||
|
|
||||||
// 5 -> not legacy user
|
// 5 -> not legacy user
|
||||||
_userService.IsLegacyUser(Arg.Any<string>())
|
_userService.IsLegacyUser(Arg.Any<string>())
|
||||||
@@ -163,13 +180,17 @@ public class BaseRequestValidatorTests
|
|||||||
.LogUserEventAsync(context.CustomValidatorRequestContext.User.Id, EventType.User_FailedLogIn);
|
.LogUserEventAsync(context.CustomValidatorRequestContext.User.Id, EventType.User_FailedLogIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_DeviceValidated_ShouldSucceed(
|
public async Task ValidateAsync_DeviceValidated_ShouldSucceed(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
// 1 -> to pass
|
// 1 -> to pass
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
@@ -177,12 +198,13 @@ public class BaseRequestValidatorTests
|
|||||||
// 2 -> will result to false with no extra configuration
|
// 2 -> will result to false with no extra configuration
|
||||||
// 3 -> set two factor to be false
|
// 3 -> set two factor to be false
|
||||||
_twoFactorAuthenticationValidator
|
_twoFactorAuthenticationValidator
|
||||||
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
||||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||||
|
|
||||||
// 4 -> set up device validator to pass
|
// 4 -> set up device validator to pass
|
||||||
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
_deviceValidator
|
||||||
.Returns(Task.FromResult(true));
|
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
||||||
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
// 5 -> not legacy user
|
// 5 -> not legacy user
|
||||||
_userService.IsLegacyUser(Arg.Any<string>())
|
_userService.IsLegacyUser(Arg.Any<string>())
|
||||||
@@ -202,13 +224,17 @@ public class BaseRequestValidatorTests
|
|||||||
Assert.False(context.GrantResult.IsError);
|
Assert.False(context.GrantResult.IsError);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_ValidatedAuthRequest_ConsumedOnSuccess(
|
public async Task ValidateAsync_ValidatedAuthRequest_ConsumedOnSuccess(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
// 1 -> to pass
|
// 1 -> to pass
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
@@ -235,7 +261,8 @@ public class BaseRequestValidatorTests
|
|||||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||||
|
|
||||||
// 4 -> set up device validator to pass
|
// 4 -> set up device validator to pass
|
||||||
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
_deviceValidator
|
||||||
|
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
||||||
.Returns(Task.FromResult(true));
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
// 5 -> not legacy user
|
// 5 -> not legacy user
|
||||||
@@ -260,13 +287,17 @@ public class BaseRequestValidatorTests
|
|||||||
ar.AuthenticationDate.HasValue));
|
ar.AuthenticationDate.HasValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_ValidatedAuthRequest_NotConsumed_When2faRequired(
|
public async Task ValidateAsync_ValidatedAuthRequest_NotConsumed_When2faRequired(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
// 1 -> to pass
|
// 1 -> to pass
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
@@ -302,13 +333,17 @@ public class BaseRequestValidatorTests
|
|||||||
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any<AuthRequest>());
|
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any<AuthRequest>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_TwoFactorTokenInvalid_ShouldSendFailedTwoFactorEmail(
|
public async Task ValidateAsync_TwoFactorTokenInvalid_ShouldSendFailedTwoFactorEmail(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
var user = requestContext.User;
|
var user = requestContext.User;
|
||||||
|
|
||||||
@@ -345,13 +380,17 @@ public class BaseRequestValidatorTests
|
|||||||
Arg.Any<string>());
|
Arg.Any<string>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_TwoFactorRememberTokenExpired_ShouldNotSendFailedTwoFactorEmail(
|
public async Task ValidateAsync_TwoFactorRememberTokenExpired_ShouldNotSendFailedTwoFactorEmail(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
var user = requestContext.User;
|
var user = requestContext.User;
|
||||||
|
|
||||||
@@ -391,28 +430,34 @@ public class BaseRequestValidatorTests
|
|||||||
// Assert
|
// Assert
|
||||||
// Verify that the failed 2FA email was NOT sent for remember token expiration
|
// Verify that the failed 2FA email was NOT sent for remember token expiration
|
||||||
await _mailService.DidNotReceive()
|
await _mailService.DidNotReceive()
|
||||||
.SendFailedTwoFactorAttemptEmailAsync(Arg.Any<string>(), Arg.Any<TwoFactorProviderType>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
.SendFailedTwoFactorAttemptEmailAsync(Arg.Any<string>(), Arg.Any<TwoFactorProviderType>(),
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<string>());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test grantTypes that require SSO when a user is in an organization that requires it
|
// Test grantTypes that require SSO when a user is in an organization that requires it
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData("password")]
|
[BitAutoData("password", true)]
|
||||||
[BitAutoData("webauthn")]
|
[BitAutoData("password", false)]
|
||||||
[BitAutoData("refresh_token")]
|
[BitAutoData("webauthn", true)]
|
||||||
|
[BitAutoData("webauthn", false)]
|
||||||
|
[BitAutoData("refresh_token", true)]
|
||||||
|
[BitAutoData("refresh_token", false)]
|
||||||
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredTrue_ShouldSetSsoResult(
|
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredTrue_ShouldSetSsoResult(
|
||||||
string grantType,
|
string grantType,
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
|
|
||||||
context.ValidatedTokenRequest.GrantType = grantType;
|
context.ValidatedTokenRequest.GrantType = grantType;
|
||||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||||
.Returns(Task.FromResult(true));
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _sut.ValidateAsync(context);
|
await _sut.ValidateAsync(context);
|
||||||
@@ -425,16 +470,21 @@ public class BaseRequestValidatorTests
|
|||||||
|
|
||||||
// Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled
|
// Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData("password")]
|
[BitAutoData("password", true)]
|
||||||
[BitAutoData("webauthn")]
|
[BitAutoData("password", false)]
|
||||||
[BitAutoData("refresh_token")]
|
[BitAutoData("webauthn", true)]
|
||||||
|
[BitAutoData("webauthn", false)]
|
||||||
|
[BitAutoData("refresh_token", true)]
|
||||||
|
[BitAutoData("refresh_token", false)]
|
||||||
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredTrue_ShouldSetSsoResult(
|
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredTrue_ShouldSetSsoResult(
|
||||||
string grantType,
|
string grantType,
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
@@ -449,23 +499,28 @@ public class BaseRequestValidatorTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
||||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||||
Assert.True(context.GrantResult.IsError);
|
Assert.True(context.GrantResult.IsError);
|
||||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||||
Assert.Equal("SSO authentication is required.", errorResponse.Message);
|
Assert.Equal("SSO authentication is required.", errorResponse.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData("password")]
|
[BitAutoData("password", true)]
|
||||||
[BitAutoData("webauthn")]
|
[BitAutoData("password", false)]
|
||||||
[BitAutoData("refresh_token")]
|
[BitAutoData("webauthn", true)]
|
||||||
|
[BitAutoData("webauthn", false)]
|
||||||
|
[BitAutoData("refresh_token", true)]
|
||||||
|
[BitAutoData("refresh_token", false)]
|
||||||
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredFalse_ShouldSucceed(
|
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredFalse_ShouldSucceed(
|
||||||
string grantType,
|
string grantType,
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
@@ -500,24 +555,29 @@ public class BaseRequestValidatorTests
|
|||||||
// Test grantTypes where SSO would be required but the user is not in an
|
// Test grantTypes where SSO would be required but the user is not in an
|
||||||
// organization that requires it
|
// organization that requires it
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData("password")]
|
[BitAutoData("password", true)]
|
||||||
[BitAutoData("webauthn")]
|
[BitAutoData("password", false)]
|
||||||
[BitAutoData("refresh_token")]
|
[BitAutoData("webauthn", true)]
|
||||||
|
[BitAutoData("webauthn", false)]
|
||||||
|
[BitAutoData("refresh_token", true)]
|
||||||
|
[BitAutoData("refresh_token", false)]
|
||||||
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredFalse_ShouldSucceed(
|
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredFalse_ShouldSucceed(
|
||||||
string grantType,
|
string grantType,
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
|
|
||||||
context.ValidatedTokenRequest.GrantType = grantType;
|
context.ValidatedTokenRequest.GrantType = grantType;
|
||||||
|
|
||||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||||
.Returns(Task.FromResult(false));
|
.Returns(Task.FromResult(false));
|
||||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||||
@@ -540,20 +600,23 @@ public class BaseRequestValidatorTests
|
|||||||
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
|
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
|
||||||
|
|
||||||
Assert.False(context.GrantResult.IsError);
|
Assert.False(context.GrantResult.IsError);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test the grantTypes where SSO is in progress or not relevant
|
// Test the grantTypes where SSO is in progress or not relevant
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData("authorization_code")]
|
[BitAutoData("authorization_code", true)]
|
||||||
[BitAutoData("client_credentials")]
|
[BitAutoData("authorization_code", false)]
|
||||||
|
[BitAutoData("client_credentials", true)]
|
||||||
|
[BitAutoData("client_credentials", false)]
|
||||||
public async Task ValidateAsync_GrantTypes_SsoRequiredFalse_ShouldSucceed(
|
public async Task ValidateAsync_GrantTypes_SsoRequiredFalse_ShouldSucceed(
|
||||||
string grantType,
|
string grantType,
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
_sut.isValid = true;
|
_sut.isValid = true;
|
||||||
|
|
||||||
@@ -577,7 +640,7 @@ public class BaseRequestValidatorTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
||||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||||
await _eventService.Received(1).LogUserEventAsync(
|
await _eventService.Received(1).LogUserEventAsync(
|
||||||
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
|
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
|
||||||
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
|
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
|
||||||
@@ -588,13 +651,17 @@ public class BaseRequestValidatorTests
|
|||||||
/* Logic Path
|
/* Logic Path
|
||||||
* ValidateAsync -> UserService.IsLegacyUser -> FailAuthForLegacyUserAsync
|
* ValidateAsync -> UserService.IsLegacyUser -> FailAuthForLegacyUserAsync
|
||||||
*/
|
*/
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
|
public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
var user = context.CustomValidatorRequestContext.User;
|
var user = context.CustomValidatorRequestContext.User;
|
||||||
user.Key = null;
|
user.Key = null;
|
||||||
@@ -613,21 +680,27 @@ public class BaseRequestValidatorTests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.True(context.GrantResult.IsError);
|
Assert.True(context.GrantResult.IsError);
|
||||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||||
var expectedMessage = "Legacy encryption without a userkey is no longer supported. To recover your account, please contact support";
|
var expectedMessage =
|
||||||
|
"Legacy encryption without a userkey is no longer supported. To recover your account, please contact support";
|
||||||
Assert.Equal(expectedMessage, errorResponse.Message);
|
Assert.Equal(expectedMessage, errorResponse.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_CustomResponse_NoMasterPassword_ShouldSetUserDecryptionOptions(
|
public async Task ValidateAsync_CustomResponse_NoMasterPassword_ShouldSetUserDecryptionOptions(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>())
|
||||||
|
.Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
|
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
|
||||||
{
|
{
|
||||||
HasMasterPassword = false,
|
HasMasterPassword = false,
|
||||||
@@ -663,19 +736,24 @@ public class BaseRequestValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)]
|
[BitAutoData(true, KdfType.PBKDF2_SHA256, 654_321, null, null)]
|
||||||
[BitAutoData(KdfType.Argon2id, 11, 128, 5)]
|
[BitAutoData(false, KdfType.PBKDF2_SHA256, 654_321, null, null)]
|
||||||
|
[BitAutoData(true, KdfType.Argon2id, 11, 128, 5)]
|
||||||
|
[BitAutoData(false, KdfType.Argon2id, 11, 128, 5)]
|
||||||
public async Task ValidateAsync_CustomResponse_MasterPassword_ShouldSetUserDecryptionOptions(
|
public async Task ValidateAsync_CustomResponse_MasterPassword_ShouldSetUserDecryptionOptions(
|
||||||
|
bool featureFlagValue,
|
||||||
KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism,
|
KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>())
|
||||||
|
.Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
|
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
|
||||||
{
|
{
|
||||||
HasMasterPassword = true,
|
HasMasterPassword = true,
|
||||||
@@ -728,13 +806,17 @@ public class BaseRequestValidatorTests
|
|||||||
Assert.Equal("test@example.com", userDecryptionOptions.MasterPasswordUnlock.Salt);
|
Assert.Equal("test@example.com", userDecryptionOptions.MasterPasswordUnlock.Salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_CustomResponse_ShouldIncludeAccountKeys(
|
public async Task ValidateAsync_CustomResponse_ShouldIncludeAccountKeys(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var mockAccountKeys = new UserAccountKeysData
|
var mockAccountKeys = new UserAccountKeysData
|
||||||
{
|
{
|
||||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||||
@@ -747,11 +829,7 @@ public class BaseRequestValidatorTests
|
|||||||
"test-wrapped-signing-key",
|
"test-wrapped-signing-key",
|
||||||
"test-verifying-key"
|
"test-verifying-key"
|
||||||
),
|
),
|
||||||
SecurityStateData = new SecurityStateData
|
SecurityStateData = new SecurityStateData { SecurityState = "test-security-state", SecurityVersion = 2 }
|
||||||
{
|
|
||||||
SecurityState = "test-security-state",
|
|
||||||
SecurityVersion = 2
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(mockAccountKeys);
|
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(mockAccountKeys);
|
||||||
@@ -759,7 +837,8 @@ public class BaseRequestValidatorTests
|
|||||||
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>())
|
||||||
|
.Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
|
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
|
||||||
{
|
{
|
||||||
HasMasterPassword = true,
|
HasMasterPassword = true,
|
||||||
@@ -808,13 +887,18 @@ public class BaseRequestValidatorTests
|
|||||||
Assert.Equal("test-security-state", accountKeysResponse.SecurityState.SecurityState);
|
Assert.Equal("test-security-state", accountKeysResponse.SecurityState.SecurityState);
|
||||||
Assert.Equal(2, accountKeysResponse.SecurityState.SecurityVersion);
|
Assert.Equal(2, accountKeysResponse.SecurityState.SecurityVersion);
|
||||||
}
|
}
|
||||||
[Theory, BitAutoData]
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_SkippedWhenPrivateKeyIsNull(
|
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_SkippedWhenPrivateKeyIsNull(
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
bool featureFlagValue,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
GrantValidationResult grantResult)
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
requestContext.User.PrivateKey = null;
|
requestContext.User.PrivateKey = null;
|
||||||
|
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
@@ -833,13 +917,18 @@ public class BaseRequestValidatorTests
|
|||||||
// Verify that the account keys query wasn't called.
|
// Verify that the account keys query wasn't called.
|
||||||
await _userAccountKeysQuery.Received(0).Run(Arg.Any<User>());
|
await _userAccountKeysQuery.Received(0).Run(Arg.Any<User>());
|
||||||
}
|
}
|
||||||
[Theory, BitAutoData]
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_CalledWithCorrectUser(
|
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_CalledWithCorrectUser(
|
||||||
|
bool featureFlagValue,
|
||||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
GrantValidationResult grantResult)
|
GrantValidationResult grantResult)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
|
||||||
var expectedUser = requestContext.User;
|
var expectedUser = requestContext.User;
|
||||||
|
|
||||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||||
@@ -853,7 +942,8 @@ public class BaseRequestValidatorTests
|
|||||||
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>()).Returns(_userDecryptionOptionsBuilder);
|
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>())
|
||||||
|
.Returns(_userDecryptionOptionsBuilder);
|
||||||
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions()));
|
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions()));
|
||||||
|
|
||||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
@@ -874,6 +964,285 @@ public class BaseRequestValidatorTests
|
|||||||
await _userAccountKeysQuery.Received(1).Run(Arg.Is<User>(u => u.Id == expectedUser.Id));
|
await _userAccountKeysQuery.Received(1).Run(Arg.Is<User>(u => u.Id == expectedUser.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests the core PM-21153 feature: SSO-required users can use recovery codes to disable 2FA,
|
||||||
|
/// but must then authenticate via SSO with a descriptive message about the recovery.
|
||||||
|
/// This test validates:
|
||||||
|
/// 1. Validation order is changed (2FA before SSO) when recovery code is provided
|
||||||
|
/// 2. Recovery code successfully validates and sets TwoFactorRecoveryRequested flag
|
||||||
|
/// 3. SSO validation then fails with recovery-specific message
|
||||||
|
/// 4. User is NOT logged in (must authenticate via IdP)
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(true)] // Feature flag ON - new behavior
|
||||||
|
[BitAutoData(false)] // Feature flag OFF - should fail at SSO before 2FA recovery
|
||||||
|
public async Task ValidateAsync_RecoveryCodeForSsoRequiredUser_BlocksWithDescriptiveMessage(
|
||||||
|
bool featureFlagEnabled,
|
||||||
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
|
GrantValidationResult grantResult)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
|
||||||
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
|
var user = requestContext.User;
|
||||||
|
|
||||||
|
// Reset state that AutoFixture may have populated
|
||||||
|
requestContext.TwoFactorRecoveryRequested = false;
|
||||||
|
requestContext.RememberMeRequested = false;
|
||||||
|
|
||||||
|
// 1. Master password is valid
|
||||||
|
_sut.isValid = true;
|
||||||
|
|
||||||
|
// 2. SSO is required (this user is in an org that requires SSO)
|
||||||
|
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||||
|
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||||
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
|
// 3. 2FA is required
|
||||||
|
_twoFactorAuthenticationValidator
|
||||||
|
.RequiresTwoFactorAsync(user, tokenRequest)
|
||||||
|
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||||
|
|
||||||
|
// 4. Provide a RECOVERY CODE (this triggers the special validation order)
|
||||||
|
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
|
||||||
|
tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code-12345";
|
||||||
|
|
||||||
|
// 5. Recovery code is valid (UserService.RecoverTwoFactorAsync will be called internally)
|
||||||
|
_twoFactorAuthenticationValidator
|
||||||
|
.VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-12345")
|
||||||
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.ValidateAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(context.GrantResult.IsError, "Authentication should fail - SSO required after recovery");
|
||||||
|
|
||||||
|
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||||
|
|
||||||
|
if (featureFlagEnabled)
|
||||||
|
{
|
||||||
|
// NEW BEHAVIOR: Recovery succeeds, then SSO blocks with descriptive message
|
||||||
|
Assert.Equal(
|
||||||
|
"Two-factor recovery has been performed. SSO authentication is required.",
|
||||||
|
errorResponse.Message);
|
||||||
|
|
||||||
|
// Verify recovery was marked
|
||||||
|
Assert.True(requestContext.TwoFactorRecoveryRequested,
|
||||||
|
"TwoFactorRecoveryRequested flag should be set");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// LEGACY BEHAVIOR: SSO blocks BEFORE recovery can happen
|
||||||
|
Assert.Equal(
|
||||||
|
"SSO authentication is required.",
|
||||||
|
errorResponse.Message);
|
||||||
|
|
||||||
|
// Recovery never happened because SSO checked first
|
||||||
|
Assert.False(requestContext.TwoFactorRecoveryRequested,
|
||||||
|
"TwoFactorRecoveryRequested should be false (SSO blocked first)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// In both cases: User is NOT logged in
|
||||||
|
await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that validation order changes when a recovery code is PROVIDED (even if invalid).
|
||||||
|
/// This ensures the RecoveryCodeRequestForSsoRequiredUserScenario() logic is based on
|
||||||
|
/// request structure, not validation outcome. An SSO-required user who provides an
|
||||||
|
/// INVALID recovery code should:
|
||||||
|
/// 1. Have 2FA validated BEFORE SSO (new order)
|
||||||
|
/// 2. Get a 2FA error (invalid token)
|
||||||
|
/// 3. NOT get the recovery-specific SSO message (because recovery didn't complete)
|
||||||
|
/// 4. NOT be logged in
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
|
public async Task ValidateAsync_InvalidRecoveryCodeForSsoRequiredUser_FailsAt2FA(
|
||||||
|
bool featureFlagEnabled,
|
||||||
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
|
GrantValidationResult grantResult)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
|
||||||
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
|
var user = requestContext.User;
|
||||||
|
|
||||||
|
// 1. Master password is valid
|
||||||
|
_sut.isValid = true;
|
||||||
|
|
||||||
|
// 2. SSO is required
|
||||||
|
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||||
|
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||||
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
|
// 3. 2FA is required
|
||||||
|
_twoFactorAuthenticationValidator
|
||||||
|
.RequiresTwoFactorAsync(user, tokenRequest)
|
||||||
|
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||||
|
|
||||||
|
// 4. Provide a RECOVERY CODE (triggers validation order change)
|
||||||
|
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
|
||||||
|
tokenRequest.Raw["TwoFactorToken"] = "INVALID-recovery-code";
|
||||||
|
|
||||||
|
// 5. Recovery code is INVALID
|
||||||
|
_twoFactorAuthenticationValidator
|
||||||
|
.VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "INVALID-recovery-code")
|
||||||
|
.Returns(Task.FromResult(false));
|
||||||
|
|
||||||
|
// 6. Setup for failed 2FA email (if feature flag enabled)
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.ValidateAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(context.GrantResult.IsError, "Authentication should fail - invalid recovery code");
|
||||||
|
|
||||||
|
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||||
|
|
||||||
|
if (featureFlagEnabled)
|
||||||
|
{
|
||||||
|
// NEW BEHAVIOR: 2FA is checked first (due to recovery code request), fails with 2FA error
|
||||||
|
Assert.Equal(
|
||||||
|
"Two-step token is invalid. Try again.",
|
||||||
|
errorResponse.Message);
|
||||||
|
|
||||||
|
// Recovery was attempted but failed - flag should NOT be set
|
||||||
|
Assert.False(requestContext.TwoFactorRecoveryRequested,
|
||||||
|
"TwoFactorRecoveryRequested should be false (recovery failed)");
|
||||||
|
|
||||||
|
// Verify failed 2FA email was sent
|
||||||
|
await _mailService.Received(1).SendFailedTwoFactorAttemptEmailAsync(
|
||||||
|
user.Email,
|
||||||
|
TwoFactorProviderType.RecoveryCode,
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
Arg.Any<string>());
|
||||||
|
|
||||||
|
// Verify failed login event was logged
|
||||||
|
await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_FailedLogIn2fa);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// LEGACY BEHAVIOR: SSO is checked first, blocks before 2FA
|
||||||
|
Assert.Equal(
|
||||||
|
"SSO authentication is required.",
|
||||||
|
errorResponse.Message);
|
||||||
|
|
||||||
|
// 2FA validation never happened
|
||||||
|
await _mailService.DidNotReceive().SendFailedTwoFactorAttemptEmailAsync(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<TwoFactorProviderType>(),
|
||||||
|
Arg.Any<DateTime>(),
|
||||||
|
Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// In both cases: User is NOT logged in
|
||||||
|
await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn);
|
||||||
|
|
||||||
|
// Verify user failed login count was updated (in new behavior path)
|
||||||
|
if (featureFlagEnabled)
|
||||||
|
{
|
||||||
|
await _userRepository.Received(1).ReplaceAsync(Arg.Is<User>(u =>
|
||||||
|
u.Id == user.Id && u.FailedLoginCount > 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that non-SSO users can successfully use recovery codes to disable 2FA and log in.
|
||||||
|
/// This validates:
|
||||||
|
/// 1. Validation order changes to 2FA-first when recovery code is provided
|
||||||
|
/// 2. Recovery code validates successfully
|
||||||
|
/// 3. SSO check passes (user not in SSO-required org)
|
||||||
|
/// 4. User successfully logs in
|
||||||
|
/// 5. TwoFactorRecoveryRequested flag is set (for logging/audit purposes)
|
||||||
|
/// This is the "happy path" for recovery code usage.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(true)]
|
||||||
|
[BitAutoData(false)]
|
||||||
|
public async Task ValidateAsync_RecoveryCodeForNonSsoUser_SuccessfulLogin(
|
||||||
|
bool featureFlagEnabled,
|
||||||
|
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||||
|
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||||
|
GrantValidationResult grantResult)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
|
||||||
|
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||||
|
var user = requestContext.User;
|
||||||
|
|
||||||
|
// 1. Master password is valid
|
||||||
|
_sut.isValid = true;
|
||||||
|
|
||||||
|
// 2. SSO is NOT required (this is a regular user, not in SSO org)
|
||||||
|
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||||
|
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||||
|
.Returns(Task.FromResult(false));
|
||||||
|
|
||||||
|
// 3. 2FA is required
|
||||||
|
_twoFactorAuthenticationValidator
|
||||||
|
.RequiresTwoFactorAsync(user, tokenRequest)
|
||||||
|
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||||
|
|
||||||
|
// 4. Provide a RECOVERY CODE
|
||||||
|
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
|
||||||
|
tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code-67890";
|
||||||
|
|
||||||
|
// 5. Recovery code is valid
|
||||||
|
_twoFactorAuthenticationValidator
|
||||||
|
.VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-67890")
|
||||||
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
|
// 6. Device validation passes
|
||||||
|
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||||
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
|
// 7. User is not legacy
|
||||||
|
_userService.IsLegacyUser(Arg.Any<string>())
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
// 8. Setup user account keys for successful login response
|
||||||
|
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||||
|
{
|
||||||
|
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||||
|
"test-private-key",
|
||||||
|
"test-public-key"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.ValidateAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(context.GrantResult.IsError, "Authentication should succeed for non-SSO user with valid recovery code");
|
||||||
|
|
||||||
|
// Verify user successfully logged in
|
||||||
|
await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_LoggedIn);
|
||||||
|
|
||||||
|
// Verify failed login count was reset (successful login)
|
||||||
|
await _userRepository.Received(1).ReplaceAsync(Arg.Is<User>(u =>
|
||||||
|
u.Id == user.Id && u.FailedLoginCount == 0));
|
||||||
|
|
||||||
|
if (featureFlagEnabled)
|
||||||
|
{
|
||||||
|
// NEW BEHAVIOR: Recovery flag should be set for audit purposes
|
||||||
|
Assert.True(requestContext.TwoFactorRecoveryRequested,
|
||||||
|
"TwoFactorRecoveryRequested flag should be set for audit/logging");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// LEGACY BEHAVIOR: Recovery flag doesn't exist, but login still succeeds
|
||||||
|
// (SSO check happens before 2FA in legacy, but user is not SSO-required so both pass)
|
||||||
|
Assert.False(requestContext.TwoFactorRecoveryRequested,
|
||||||
|
"TwoFactorRecoveryRequested should be false in legacy mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private BaseRequestValidationContextFake CreateContext(
|
private BaseRequestValidationContextFake CreateContext(
|
||||||
ValidatedTokenRequest tokenRequest,
|
ValidatedTokenRequest tokenRequest,
|
||||||
CustomValidatorRequestContext requestContext,
|
CustomValidatorRequestContext requestContext,
|
||||||
|
|||||||
Reference in New Issue
Block a user