mirror of
https://github.com/bitwarden/server.git
synced 2025-02-07 00:21:32 +01:00
[PM-8220] New Device Verification (#5084)
* feat(BaseRequestValidator): Add global setting for new device verification. Refactor BaseRequestValidator enabling better self-documenting code and better single responsibility principle for validators. Updated DeviceValidator to handle new device verification, behind a feature flag. Moved IDeviceValidator interface to separate file. Updated CustomRequestValidator to act as the conduit by which *Validators communicate authentication context between themselves and the RequestValidators. Adding new test for DeviceValidator class. Updated tests for BaseRequestValidator as some functionality was moved to the DeviceValidator class.
This commit is contained in:
parent
a76a9cb800
commit
867fa848dd
@ -329,7 +329,7 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
{
|
||||
// We validate open registration on send of initial email and here b/c a user could technically start the
|
||||
// account creation process while open registration is enabled and then finish it after it has been
|
||||
// disabled by the self hosted admin.Ï
|
||||
// disabled by the self hosted admin.
|
||||
if (_globalSettings.DisableUserRegistration)
|
||||
{
|
||||
throw new BadRequestException(_disabledUserRegistrationExceptionMsg);
|
||||
|
@ -41,6 +41,7 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual string HibpApiKey { get; set; }
|
||||
public virtual bool DisableUserRegistration { get; set; }
|
||||
public virtual bool DisableEmailNewDevice { get; set; }
|
||||
public virtual bool EnableNewDeviceVerification { get; set; }
|
||||
public virtual bool EnableCloudCommunication { get; set; } = false;
|
||||
public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days
|
||||
public virtual string EventGridKey { get; set; }
|
||||
@ -433,18 +434,18 @@ public class GlobalSettings : IGlobalSettings
|
||||
public bool EnableSendTracing { get; set; } = false;
|
||||
/// <summary>
|
||||
/// The date and time at which registration will be enabled.
|
||||
///
|
||||
///
|
||||
/// **This value should not be updated once set, as it is used to determine installation location of devices.**
|
||||
///
|
||||
///
|
||||
/// If null, registration is disabled.
|
||||
///
|
||||
///
|
||||
/// </summary>
|
||||
public DateTime? RegistrationStartDate { get; set; }
|
||||
/// <summary>
|
||||
/// The date and time at which registration will be disabled.
|
||||
///
|
||||
///
|
||||
/// **This value should not be updated once set, as it is used to determine installation location of devices.**
|
||||
///
|
||||
///
|
||||
/// If null, hub registration has no yet known expiry.
|
||||
/// </summary>
|
||||
public DateTime? RegistrationEndDate { get; set; }
|
||||
@ -454,7 +455,7 @@ public class GlobalSettings : IGlobalSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// List of Notification Hub settings to use for sending push notifications.
|
||||
///
|
||||
///
|
||||
/// Note that hubs on the same namespace share active device limits, so multiple namespaces should be used to increase capacity.
|
||||
/// </summary>
|
||||
public List<NotificationHubSettings> NotificationHubs { get; set; } = new();
|
||||
|
@ -14,6 +14,7 @@ public interface IGlobalSettings
|
||||
string LicenseCertificatePassword { get; set; }
|
||||
int OrganizationInviteExpirationHours { get; set; }
|
||||
bool DisableUserRegistration { get; set; }
|
||||
bool EnableNewDeviceVerification { get; set; }
|
||||
IInstallationSettings Installation { get; set; }
|
||||
IFileStorageSettings Attachment { get; set; }
|
||||
IConnectionStringSettings Storage { get; set; }
|
||||
|
@ -1,11 +1,43 @@
|
||||
using Bit.Core.Auth.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer;
|
||||
|
||||
public class CustomValidatorRequestContext
|
||||
{
|
||||
public User User { get; set; }
|
||||
/// <summary>
|
||||
/// This is the device that the user is using to authenticate. It can be either known or unknown.
|
||||
/// We set it here since the ResourceOwnerPasswordValidator needs the device to know if CAPTCHA is required.
|
||||
/// The option to set it here saves a trip to the database.
|
||||
/// </summary>
|
||||
public Device Device { get; set; }
|
||||
/// <summary>
|
||||
/// Communicates whether or not the device in the request is known to the user.
|
||||
/// KnownDevice is set in the child classes of the BaseRequestValidator using the DeviceValidator.KnownDeviceAsync method.
|
||||
/// Except in the CustomTokenRequestValidator, where it is hardcoded to true.
|
||||
/// </summary>
|
||||
public bool KnownDevice { get; set; }
|
||||
/// <summary>
|
||||
/// This communicates whether or not two factor is required for the user to authenticate.
|
||||
/// </summary>
|
||||
public bool TwoFactorRequired { get; set; } = false;
|
||||
/// <summary>
|
||||
/// This communicates whether or not SSO is required for the user to authenticate.
|
||||
/// </summary>
|
||||
public bool SsoRequired { get; set; } = false;
|
||||
/// <summary>
|
||||
/// We use the parent class for both GrantValidationResult and TokenRequestValidationResult here for
|
||||
/// flexibility when building an error response.
|
||||
/// This will be null if the authentication request is successful.
|
||||
/// </summary>
|
||||
public ValidationResult ValidationErrorResult { get; set; }
|
||||
/// <summary>
|
||||
/// This dictionary should contain relevant information for the clients to act on.
|
||||
/// This will contain the information used to guide a user to successful authentication, such as TwoFactorProviders.
|
||||
/// This will be null if the authentication request is successful.
|
||||
/// </summary>
|
||||
public Dictionary<string, object> CustomResponse { get; set; }
|
||||
public CaptchaResponse CaptchaResponse { get; set; }
|
||||
}
|
||||
|
@ -0,0 +1,10 @@
|
||||
namespace Bit.Identity.IdentityServer.Enums;
|
||||
|
||||
public enum DeviceValidationResultType : byte
|
||||
{
|
||||
Success = 0,
|
||||
InvalidUser = 1,
|
||||
InvalidNewDeviceOtp = 2,
|
||||
NewDeviceVerificationRequired = 3,
|
||||
NoDeviceInformationProvided = 4
|
||||
}
|
@ -77,37 +77,51 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
||||
CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
// 1. we need to check if the user is a bot and if their master password hash is correct
|
||||
var isBot = validatorContext.CaptchaResponse?.IsBot ?? false;
|
||||
if (isBot)
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||
"Login attempt for {0} detected as a captcha bot with score {1}.",
|
||||
request.UserName, validatorContext.CaptchaResponse.Score);
|
||||
}
|
||||
|
||||
var valid = await ValidateContextAsync(context, validatorContext);
|
||||
var user = validatorContext.User;
|
||||
if (!valid)
|
||||
{
|
||||
await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice);
|
||||
}
|
||||
|
||||
if (!valid || isBot)
|
||||
{
|
||||
if (isBot)
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||
"Login attempt for {UserName} detected as a captcha bot with score {CaptchaScore}.",
|
||||
request.UserName, validatorContext.CaptchaResponse.Score);
|
||||
}
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice);
|
||||
}
|
||||
|
||||
await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
|
||||
return;
|
||||
}
|
||||
|
||||
var (isTwoFactorRequired, twoFactorOrganization) = await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
|
||||
var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString();
|
||||
var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString();
|
||||
var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1";
|
||||
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
|
||||
!string.IsNullOrWhiteSpace(twoFactorProvider);
|
||||
|
||||
if (isTwoFactorRequired)
|
||||
// 2. Does this user belong to an organization that requires SSO
|
||||
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
|
||||
if (validatorContext.SsoRequired)
|
||||
{
|
||||
// 2FA required and not provided response
|
||||
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"]?.ToString();
|
||||
var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString();
|
||||
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
|
||||
!string.IsNullOrWhiteSpace(twoFactorProvider);
|
||||
// response for 2FA required and not provided state
|
||||
if (!validTwoFactorRequest ||
|
||||
!Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
|
||||
{
|
||||
@ -125,18 +139,14 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
return;
|
||||
}
|
||||
|
||||
var verified = await _twoFactorAuthenticationValidator
|
||||
var twoFactorTokenValid = await _twoFactorAuthenticationValidator
|
||||
.VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
|
||||
|
||||
// 2FA required but request not valid or remember token expired response
|
||||
if (!verified || isBot)
|
||||
// response for 2FA required but request is not valid or remember token expired state
|
||||
if (!twoFactorTokenValid)
|
||||
{
|
||||
if (twoFactorProviderType != TwoFactorProviderType.Remember)
|
||||
{
|
||||
await UpdateFailedAuthDetailsAsync(user, true, !validatorContext.KnownDevice);
|
||||
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
|
||||
}
|
||||
else if (twoFactorProviderType == TwoFactorProviderType.Remember)
|
||||
// The remember me token has expired
|
||||
if (twoFactorProviderType == TwoFactorProviderType.Remember)
|
||||
{
|
||||
var resultDict = await _twoFactorAuthenticationValidator
|
||||
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
|
||||
@ -145,16 +155,34 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
|
||||
SetTwoFactorResult(context, resultDict);
|
||||
}
|
||||
else
|
||||
{
|
||||
await UpdateFailedAuthDetailsAsync(user, true, !validatorContext.KnownDevice);
|
||||
await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
validTwoFactorRequest = false;
|
||||
twoFactorRemember = false;
|
||||
|
||||
// When the two factor authentication is successful, we can check if the user wants a rememberMe token
|
||||
var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1";
|
||||
if (twoFactorRemember // Check if the user wants a rememberMe token
|
||||
&& twoFactorTokenValid // Make sure two factor authentication was successful
|
||||
&& twoFactorProviderType != TwoFactorProviderType.Remember) // if the two factor auth was rememberMe do not send another token
|
||||
{
|
||||
returnRememberMeToken = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Force legacy users to the web for migration
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 5. Force legacy users to the web for migration
|
||||
if (FeatureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers))
|
||||
{
|
||||
if (UserService.IsLegacyUser(user) && request.ClientId != "web")
|
||||
@ -164,24 +192,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
}
|
||||
}
|
||||
|
||||
if (await IsValidAuthTypeAsync(user, request.GrantType))
|
||||
{
|
||||
var device = await _deviceValidator.SaveDeviceAsync(user, request);
|
||||
if (device == null)
|
||||
{
|
||||
await BuildErrorResultAsync("No device information provided.", false, context, user);
|
||||
return;
|
||||
}
|
||||
await BuildSuccessResultAsync(user, context, device, validTwoFactorRequest && twoFactorRemember);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetSsoResult(context,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||
});
|
||||
}
|
||||
await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
|
||||
}
|
||||
|
||||
protected async Task FailAuthForLegacyUserAsync(User user, T context)
|
||||
@ -235,6 +246,17 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
await SetSuccessResult(context, user, claims, customResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This does two things, it sets the error result for the current ValidatorContext _and_ it logs error.
|
||||
/// These two things should be seperated to maintain single concerns.
|
||||
/// </summary>
|
||||
/// <param name="message">Error message for the error result</param>
|
||||
/// <param name="twoFactorRequest">bool that controls how the error is logged</param>
|
||||
/// <param name="context">used to set the error result in the current validator</param>
|
||||
/// <param name="user">used to associate the failed login with a user</param>
|
||||
/// <returns>void</returns>
|
||||
[Obsolete("Consider using SetValidationErrorResult to set the validation result, and LogFailedLoginEvent " +
|
||||
"to log the failure.")]
|
||||
protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user)
|
||||
{
|
||||
if (user != null)
|
||||
@ -255,41 +277,80 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
new Dictionary<string, object> { { "ErrorModel", new ErrorResponseModel(message) } });
|
||||
}
|
||||
|
||||
protected async Task LogFailedLoginEvent(User user, EventType eventType)
|
||||
{
|
||||
if (user != null)
|
||||
{
|
||||
await _eventService.LogUserEventAsync(user.Id, eventType);
|
||||
}
|
||||
|
||||
if (_globalSettings.SelfHosted)
|
||||
{
|
||||
string formattedMessage;
|
||||
switch (eventType)
|
||||
{
|
||||
case EventType.User_FailedLogIn:
|
||||
formattedMessage = string.Format("Failed login attempt. {0}", $" {CurrentContext.IpAddress}");
|
||||
break;
|
||||
case EventType.User_FailedLogIn2fa:
|
||||
formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}", $" {CurrentContext.IpAddress}");
|
||||
break;
|
||||
default:
|
||||
formattedMessage = "Failed login attempt.";
|
||||
break;
|
||||
}
|
||||
_logger.LogWarning(Constants.BypassFiltersEventId, formattedMessage);
|
||||
}
|
||||
await Task.Delay(2000); // Delay for brute force.
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||
protected abstract void SetTwoFactorResult(T context, Dictionary<string, object> customResponse);
|
||||
|
||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||
protected abstract void SetSsoResult(T context, Dictionary<string, object> customResponse);
|
||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
|
||||
|
||||
/// <summary>
|
||||
/// This consumes the ValidationErrorResult property in the CustomValidatorRequestContext and sets
|
||||
/// it appropriately in the response object for the token and grant validators.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
protected abstract void SetValidationErrorResult(T context, CustomValidatorRequestContext requestContext);
|
||||
protected abstract Task SetSuccessResult(T context, User user, List<Claim> claims,
|
||||
Dictionary<string, object> customResponse);
|
||||
|
||||
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
|
||||
protected abstract ClaimsPrincipal GetSubject(T context);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are
|
||||
/// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement.
|
||||
/// If the GrantType is authorization_code or client_credentials we know the user is trying to login
|
||||
/// using the SSO flow so they are allowed to continue.
|
||||
/// </summary>
|
||||
/// <param name="user">user trying to login</param>
|
||||
/// <param name="grantType">magic string identifying the grant type requested</param>
|
||||
/// <returns></returns>
|
||||
private async Task<bool> IsValidAuthTypeAsync(User user, string grantType)
|
||||
/// <returns>true if sso required; false if not required or already in process</returns>
|
||||
private async Task<bool> RequireSsoLoginAsync(User user, string grantType)
|
||||
{
|
||||
if (grantType == "authorization_code" || grantType == "client_credentials")
|
||||
{
|
||||
// Already using SSO to authorize, finish successfully
|
||||
// Or login via api key, skip SSO requirement
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user belongs to any organization with an active SSO policy
|
||||
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
if (anySsoPoliciesApplicableToUser)
|
||||
{
|
||||
// Already using SSO to authenticate, or logging-in via api key to skip SSO requirement
|
||||
// allow to authenticate successfully
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default - continue validation process
|
||||
return true;
|
||||
// Check if user belongs to any organization with an active SSO policy
|
||||
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(
|
||||
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
if (anySsoPoliciesApplicableToUser)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default - SSO is not required
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task ResetFailedAuthDetailsAsync(User user)
|
||||
@ -350,7 +411,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
|
||||
.ToList();
|
||||
|
||||
if (!orgs.Any())
|
||||
if (orgs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
@ -89,8 +89,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ValidateAsync(context, context.Result.ValidatedRequest,
|
||||
new CustomValidatorRequestContext { KnownDevice = true });
|
||||
await ValidateAsync(context, context.Result.ValidatedRequest, new CustomValidatorRequestContext { });
|
||||
}
|
||||
|
||||
protected async override Task<bool> ValidateContextAsync(CustomTokenRequestValidationContext context,
|
||||
@ -162,6 +161,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
return context.Result.ValidatedRequest.Subject;
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
|
||||
protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
@ -172,16 +172,18 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
context.Result.CustomResponse = customResponse;
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
|
||||
protected override void SetSsoResult(CustomTokenRequestValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
context.Result.Error = "invalid_grant";
|
||||
context.Result.ErrorDescription = "Single Sign on required.";
|
||||
context.Result.ErrorDescription = "Sso authentication required.";
|
||||
context.Result.IsError = true;
|
||||
context.Result.CustomResponse = customResponse;
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
|
||||
protected override void SetErrorResult(CustomTokenRequestValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
@ -190,4 +192,14 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
context.Result.IsError = true;
|
||||
context.Result.CustomResponse = customResponse;
|
||||
}
|
||||
|
||||
protected override void SetValidationErrorResult(
|
||||
CustomTokenRequestValidationContext context, CustomValidatorRequestContext requestContext)
|
||||
{
|
||||
Debug.Assert(context.Result is not null);
|
||||
context.Result.Error = requestContext.ValidationErrorResult.Error;
|
||||
context.Result.IsError = requestContext.ValidationErrorResult.IsError;
|
||||
context.Result.ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription;
|
||||
context.Result.CustomResponse = requestContext.CustomResponse;
|
||||
}
|
||||
}
|
||||
|
@ -1,95 +1,162 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Reflection;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Identity.IdentityServer.Enums;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public interface IDeviceValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Save a device to the database. If the device is already known, it will be returned.
|
||||
/// </summary>
|
||||
/// <param name="user">The user is assumed NOT null, still going to check though</param>
|
||||
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
|
||||
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
|
||||
Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request);
|
||||
/// <summary>
|
||||
/// Check if a device is known to the user.
|
||||
/// </summary>
|
||||
/// <param name="user">current user trying to authenticate</param>
|
||||
/// <param name="request">contains raw information that is parsed about the device</param>
|
||||
/// <returns>true if the device is known, false if it is not</returns>
|
||||
Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request);
|
||||
}
|
||||
|
||||
public class DeviceValidator(
|
||||
IDeviceService deviceService,
|
||||
IDeviceRepository deviceRepository,
|
||||
GlobalSettings globalSettings,
|
||||
IMailService mailService,
|
||||
ICurrentContext currentContext) : IDeviceValidator
|
||||
ICurrentContext currentContext,
|
||||
IUserService userService,
|
||||
IFeatureService featureService) : IDeviceValidator
|
||||
{
|
||||
private readonly IDeviceService _deviceService = deviceService;
|
||||
private readonly IDeviceRepository _deviceRepository = deviceRepository;
|
||||
private readonly GlobalSettings _globalSettings = globalSettings;
|
||||
private readonly IMailService _mailService = mailService;
|
||||
private readonly ICurrentContext _currentContext = currentContext;
|
||||
private readonly IUserService _userService = userService;
|
||||
private readonly IFeatureService _featureService = featureService;
|
||||
|
||||
/// <summary>
|
||||
/// Save a device to the database. If the device is already known, it will be returned.
|
||||
/// </summary>
|
||||
/// <param name="user">The user is assumed NOT null, still going to check though</param>
|
||||
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
|
||||
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
|
||||
public async Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request)
|
||||
public async Task<bool> ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context)
|
||||
{
|
||||
var device = GetDeviceFromRequest(request);
|
||||
if (device != null && user != null)
|
||||
// Parse device from request and return early if no device information is provided
|
||||
var requestDevice = context.Device ?? GetDeviceFromRequest(request);
|
||||
// If context.Device and request device information are null then return error
|
||||
// backwards compatibility -- check if user is null
|
||||
// PM-13340: Null user check happens in the HandleNewDeviceVerificationAsync method and can be removed from here
|
||||
if (requestDevice == null || context.User == null)
|
||||
{
|
||||
var existingDevice = await GetKnownDeviceAsync(user, device);
|
||||
if (existingDevice == null)
|
||||
{
|
||||
device.UserId = user.Id;
|
||||
await _deviceService.SaveAsync(device);
|
||||
|
||||
// This makes sure the user isn't sent a "new device" email on their first login
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - user.CreationDate > TimeSpan.FromMinutes(10))
|
||||
{
|
||||
var deviceType = device.Type.GetType().GetMember(device.Type.ToString())
|
||||
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
|
||||
if (!_globalSettings.DisableEmailNewDevice)
|
||||
{
|
||||
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
|
||||
_currentContext.IpAddress);
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
return existingDevice;
|
||||
(context.ValidationErrorResult, context.CustomResponse) =
|
||||
BuildDeviceErrorResult(DeviceValidationResultType.NoDeviceInformationProvided);
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
|
||||
// if not a new device request then check if the device is known
|
||||
if (!NewDeviceOtpRequest(request))
|
||||
{
|
||||
var knownDevice = await GetKnownDeviceAsync(context.User, requestDevice);
|
||||
// if the device is know then we return the device fetched from the database
|
||||
// returning the database device is important for TDE
|
||||
if (knownDevice != null)
|
||||
{
|
||||
context.KnownDevice = true;
|
||||
context.Device = knownDevice;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// We have established that the device is unknown at this point; begin new device verification
|
||||
// PM-13340: remove feature flag
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) &&
|
||||
request.GrantType == "password" &&
|
||||
request.Raw["AuthRequest"] == null &&
|
||||
!context.TwoFactorRequired &&
|
||||
!context.SsoRequired &&
|
||||
_globalSettings.EnableNewDeviceVerification)
|
||||
{
|
||||
// We only want to return early if the device is invalid or there is an error
|
||||
var validationResult = await HandleNewDeviceVerificationAsync(context.User, request);
|
||||
if (validationResult != DeviceValidationResultType.Success)
|
||||
{
|
||||
(context.ValidationErrorResult, context.CustomResponse) =
|
||||
BuildDeviceErrorResult(validationResult);
|
||||
if (validationResult == DeviceValidationResultType.NewDeviceVerificationRequired)
|
||||
{
|
||||
await _userService.SendOTPAsync(context.User);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point we have established either new device verification is not required or the NewDeviceOtp is valid
|
||||
requestDevice.UserId = context.User.Id;
|
||||
await _deviceService.SaveAsync(requestDevice);
|
||||
context.Device = requestDevice;
|
||||
|
||||
// backwards compatibility -- If NewDeviceVerification not enabled send the new login emails
|
||||
// PM-13340: removal Task; remove entire if block emails should no longer be sent
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification))
|
||||
{
|
||||
// This ensures the user doesn't receive a "new device" email on the first login
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - context.User.CreationDate > TimeSpan.FromMinutes(10))
|
||||
{
|
||||
var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString())
|
||||
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
|
||||
if (!_globalSettings.DisableEmailNewDevice)
|
||||
{
|
||||
await _mailService.SendNewDeviceLoggedInEmail(context.User.Email, deviceType, now,
|
||||
_currentContext.IpAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request) =>
|
||||
(await GetKnownDeviceAsync(user, GetDeviceFromRequest(request))) != default;
|
||||
/// <summary>
|
||||
/// Checks the if the requesting deice requires new device verification otherwise saves the device to the database
|
||||
/// </summary>
|
||||
/// <param name="user">user attempting to authenticate</param>
|
||||
/// <param name="ValidatedRequest">The Request is used to check for the NewDeviceOtp and for the raw device data</param>
|
||||
/// <returns>returns deviceValtaionResultType</returns>
|
||||
private async Task<DeviceValidationResultType> HandleNewDeviceVerificationAsync(User user, ValidatedRequest request)
|
||||
{
|
||||
// currently unreachable due to backward compatibility
|
||||
// PM-13340: will address this
|
||||
if (user == null)
|
||||
{
|
||||
return DeviceValidationResultType.InvalidUser;
|
||||
}
|
||||
|
||||
private async Task<Device> GetKnownDeviceAsync(User user, Device device)
|
||||
// parse request for NewDeviceOtp to validate
|
||||
var newDeviceOtp = request.Raw["NewDeviceOtp"]?.ToString();
|
||||
// we only check null here since an empty OTP will be considered an incorrect OTP
|
||||
if (newDeviceOtp != null)
|
||||
{
|
||||
// verify the NewDeviceOtp
|
||||
var otpValid = await _userService.VerifyOTPAsync(user, newDeviceOtp);
|
||||
if (otpValid)
|
||||
{
|
||||
return DeviceValidationResultType.Success;
|
||||
}
|
||||
return DeviceValidationResultType.InvalidNewDeviceOtp;
|
||||
}
|
||||
|
||||
// if a user has no devices they are assumed to be newly registered user which does not require new device verification
|
||||
var devices = await _deviceRepository.GetManyByUserIdAsync(user.Id);
|
||||
if (devices.Count == 0)
|
||||
{
|
||||
return DeviceValidationResultType.Success;
|
||||
}
|
||||
|
||||
// if we get to here then we need to send a new device verification email
|
||||
return DeviceValidationResultType.NewDeviceVerificationRequired;
|
||||
}
|
||||
|
||||
public async Task<Device> GetKnownDeviceAsync(User user, Device device)
|
||||
{
|
||||
if (user == null || device == null)
|
||||
{
|
||||
return default;
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id);
|
||||
}
|
||||
|
||||
private static Device GetDeviceFromRequest(ValidatedRequest request)
|
||||
public static Device GetDeviceFromRequest(ValidatedRequest request)
|
||||
{
|
||||
var deviceIdentifier = request.Raw["DeviceIdentifier"]?.ToString();
|
||||
var requestDeviceType = request.Raw["DeviceType"]?.ToString();
|
||||
@ -112,4 +179,49 @@ public class DeviceValidator(
|
||||
PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks request for the NewDeviceOtp field to determine if a new device verification is required.
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
public static bool NewDeviceOtpRequest(ValidatedRequest request)
|
||||
{
|
||||
return !string.IsNullOrEmpty(request.Raw["NewDeviceOtp"]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This builds builds the error result for the various grant and token validators. The Success type is not used here.
|
||||
/// </summary>
|
||||
/// <param name="errorType">DeviceValidationResultType that is an error, success type is not used.</param>
|
||||
/// <returns>validation result used by grant and token validators, and the custom response for either Grant or Token response objects.</returns>
|
||||
private static (Duende.IdentityServer.Validation.ValidationResult, Dictionary<string, object>) BuildDeviceErrorResult(DeviceValidationResultType errorType)
|
||||
{
|
||||
var result = new Duende.IdentityServer.Validation.ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = "device_error",
|
||||
};
|
||||
var customResponse = new Dictionary<string, object>();
|
||||
switch (errorType)
|
||||
{
|
||||
case DeviceValidationResultType.InvalidUser:
|
||||
result.ErrorDescription = "Invalid user";
|
||||
customResponse.Add("ErrorModel", new ErrorResponseModel("invalid user"));
|
||||
break;
|
||||
case DeviceValidationResultType.InvalidNewDeviceOtp:
|
||||
result.ErrorDescription = "Invalid New Device OTP";
|
||||
customResponse.Add("ErrorModel", new ErrorResponseModel("invalid new device otp"));
|
||||
break;
|
||||
case DeviceValidationResultType.NewDeviceVerificationRequired:
|
||||
result.ErrorDescription = "New device verification required";
|
||||
customResponse.Add("ErrorModel", new ErrorResponseModel("new device verification required"));
|
||||
break;
|
||||
case DeviceValidationResultType.NoDeviceInformationProvided:
|
||||
result.ErrorDescription = "No device information provided";
|
||||
customResponse.Add("ErrorModel", new ErrorResponseModel("no device information provided"));
|
||||
break;
|
||||
}
|
||||
return (result, customResponse);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,24 @@
|
||||
using Bit.Core.Entities;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public interface IDeviceValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches device from the database using the Device Identifier and the User Id to know if the user
|
||||
/// has ever tried to authenticate with this specific instance of Bitwarden.
|
||||
/// </summary>
|
||||
/// <param name="user">user attempting to authenticate</param>
|
||||
/// <param name="device">current instance of Bitwarden the user is interacting with</param>
|
||||
/// <returns>null or Device</returns>
|
||||
Task<Device> GetKnownDeviceAsync(User user, Device device);
|
||||
|
||||
/// <summary>
|
||||
/// Validate the requesting device. Modifies the ValidatorRequestContext with error result if any.
|
||||
/// </summary>
|
||||
/// <param name="request">The Request is used to check for the NewDeviceOtp and for the raw device data</param>
|
||||
/// <param name="context">Contains two factor and sso context that are important for decisions on new device verification</param>
|
||||
/// <returns>returns true if device is valid and no other action required; if false modifies the context with an error result to be returned;</returns>
|
||||
Task<bool> ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context);
|
||||
}
|
@ -75,11 +75,16 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant());
|
||||
// We want to keep this device around incase the device is new for the user
|
||||
var requestDevice = DeviceValidator.GetDeviceFromRequest(context.Request);
|
||||
var knownDevice = await _deviceValidator.GetKnownDeviceAsync(user, requestDevice);
|
||||
var validatorContext = new CustomValidatorRequestContext
|
||||
{
|
||||
User = user,
|
||||
KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request),
|
||||
KnownDevice = knownDevice != null,
|
||||
Device = knownDevice ?? requestDevice,
|
||||
};
|
||||
|
||||
string bypassToken = null;
|
||||
if (!validatorContext.KnownDevice &&
|
||||
_captchaValidationService.RequireCaptchaValidation(_currentContext, user))
|
||||
@ -156,6 +161,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
|
||||
protected override void SetTwoFactorResult(ResourceOwnerPasswordValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
@ -163,6 +169,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
customResponse);
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
|
||||
protected override void SetSsoResult(ResourceOwnerPasswordValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
@ -170,12 +177,25 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
customResponse);
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
|
||||
protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
|
||||
}
|
||||
|
||||
protected override void SetValidationErrorResult(
|
||||
ResourceOwnerPasswordValidationContext context, CustomValidatorRequestContext requestContext)
|
||||
{
|
||||
context.Result = new GrantValidationResult
|
||||
{
|
||||
Error = requestContext.ValidationErrorResult.Error,
|
||||
ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription,
|
||||
IsError = true,
|
||||
CustomResponse = requestContext.CustomResponse
|
||||
};
|
||||
}
|
||||
|
||||
protected override ClaimsPrincipal GetSubject(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
return context.Result.Subject;
|
||||
@ -183,28 +203,26 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
|
||||
private bool AuthEmailHeaderIsValid(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
if (!_currentContext.HttpContext.Request.Headers.ContainsKey("Auth-Email"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
if (_currentContext.HttpContext.Request.Headers.TryGetValue("Auth-Email", out var authEmailHeader))
|
||||
{
|
||||
try
|
||||
{
|
||||
var authEmailHeader = _currentContext.HttpContext.Request.Headers["Auth-Email"];
|
||||
var authEmailDecoded = CoreHelpers.Base64UrlDecodeString(authEmailHeader);
|
||||
|
||||
if (authEmailDecoded != context.UserName)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (System.Exception e) when (e is System.InvalidOperationException || e is System.FormatException)
|
||||
catch (Exception e) when (e is InvalidOperationException || e is FormatException)
|
||||
{
|
||||
// Invalid B64 encoding
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -91,15 +91,9 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
}
|
||||
|
||||
var (user, credential) = await _assertWebAuthnLoginCredentialCommand.AssertWebAuthnLoginCredential(token.Options, deviceResponse);
|
||||
var validatorContext = new CustomValidatorRequestContext
|
||||
{
|
||||
User = user,
|
||||
KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request)
|
||||
};
|
||||
|
||||
UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential);
|
||||
|
||||
await ValidateAsync(context, context.Request, validatorContext);
|
||||
await ValidateAsync(context, context.Request, new CustomValidatorRequestContext { User = user });
|
||||
}
|
||||
|
||||
protected override Task<bool> ValidateContextAsync(ExtensionGrantValidationContext context,
|
||||
@ -128,6 +122,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
return context.Result.Subject;
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||
protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
@ -135,6 +130,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
customResponse);
|
||||
}
|
||||
|
||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||
protected override void SetSsoResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
@ -142,9 +138,21 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
customResponse);
|
||||
}
|
||||
|
||||
protected override void SetErrorResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
[Obsolete("Consider using SetValidationErrorResult instead.")]
|
||||
protected override void SetErrorResult(ExtensionGrantValidationContext context, Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
|
||||
}
|
||||
|
||||
protected override void SetValidationErrorResult(
|
||||
ExtensionGrantValidationContext context, CustomValidatorRequestContext requestContext)
|
||||
{
|
||||
context.Result = new GrantValidationResult
|
||||
{
|
||||
Error = requestContext.ValidationErrorResult.Error,
|
||||
ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription,
|
||||
IsError = true,
|
||||
CustomResponse = requestContext.CustomResponse
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -5,14 +5,11 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Identity.IdentityServer.RequestValidators;
|
||||
using Bit.Identity.Models.Request.Accounts;
|
||||
using Bit.IntegrationTestCommon.Factories;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Identity.IntegrationTest.RequestValidation;
|
||||
@ -217,48 +214,6 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
Assert.Equal("Username or password is incorrect. Try again.", errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DeviceSaveAsync_ReturnsNullDevice_ErrorResult()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new IdentityApplicationFactory();
|
||||
|
||||
// Stub DeviceValidator
|
||||
factory.SubstituteService<IDeviceValidator>(sub =>
|
||||
{
|
||||
sub.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
|
||||
.Returns(null as Device);
|
||||
});
|
||||
|
||||
// Add User
|
||||
await factory.RegisterAsync(new RegisterRequestModel
|
||||
{
|
||||
Email = DefaultUsername,
|
||||
MasterPasswordHash = DefaultPassword
|
||||
});
|
||||
var userManager = factory.GetService<UserManager<User>>();
|
||||
await factory.RegisterAsync(new RegisterRequestModel
|
||||
{
|
||||
Email = DefaultUsername,
|
||||
MasterPasswordHash = DefaultPassword
|
||||
});
|
||||
var user = await userManager.FindByEmailAsync(DefaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Act
|
||||
var context = await factory.Server.PostAsync("/connect/token",
|
||||
GetFormUrlEncodedContent(),
|
||||
context => context.SetAuthEmail(DefaultUsername));
|
||||
|
||||
// Assert
|
||||
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = body.RootElement;
|
||||
|
||||
var errorModel = AssertHelper.AssertJsonProperty(root, "ErrorModel", JsonValueKind.Object);
|
||||
var errorMessage = AssertHelper.AssertJsonProperty(errorModel, "Message", JsonValueKind.String).GetString();
|
||||
Assert.Equal("No device information provided.", errorMessage);
|
||||
}
|
||||
|
||||
private async Task EnsureUserCreatedAsync(IdentityApplicationFactory factory = null)
|
||||
{
|
||||
factory ??= _factory;
|
||||
@ -290,6 +245,18 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
});
|
||||
}
|
||||
|
||||
private FormUrlEncodedContent GetDefaultFormUrlEncodedContentWithoutDevice()
|
||||
{
|
||||
return new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", DefaultUsername },
|
||||
{ "password", DefaultPassword },
|
||||
});
|
||||
}
|
||||
|
||||
private static string DeviceTypeAsString(DeviceType deviceType)
|
||||
{
|
||||
return ((int)deviceType).ToString();
|
||||
|
@ -22,7 +22,6 @@ using NSubstitute;
|
||||
using Xunit;
|
||||
using AuthFixtures = Bit.Identity.Test.AutoFixture;
|
||||
|
||||
|
||||
namespace Bit.Identity.Test.IdentityServer;
|
||||
|
||||
public class BaseRequestValidatorTests
|
||||
@ -82,10 +81,10 @@ public class BaseRequestValidatorTests
|
||||
}
|
||||
|
||||
/* Logic path
|
||||
ValidateAsync -> _Logger.LogInformation
|
||||
|-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|
||||
|-> SetErrorResult
|
||||
*/
|
||||
* ValidateAsync -> _Logger.LogInformation
|
||||
* |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|
||||
* |-> SetErrorResult
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_IsBot_UserNotNull_ShouldBuildErrorResult_ShouldLogFailedLoginEvent(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
@ -112,11 +111,11 @@ public class BaseRequestValidatorTests
|
||||
}
|
||||
|
||||
/* Logic path
|
||||
ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
|
||||
|-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|
||||
(self hosted) |-> _logger.LogWarning()
|
||||
|-> SetErrorResult
|
||||
*/
|
||||
* ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
|
||||
* |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|
||||
* (self hosted) |-> _logger.LogWarning()
|
||||
* |-> SetErrorResult
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
@ -140,10 +139,10 @@ public class BaseRequestValidatorTests
|
||||
}
|
||||
|
||||
/* Logic path
|
||||
ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
|
||||
|-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|
||||
|-> SetErrorResult
|
||||
*/
|
||||
* ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
|
||||
* |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|
||||
* |-> SetErrorResult
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_ContextNotValid_MaxAttemptLogin_ShouldSendEmail(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
@ -177,134 +176,97 @@ public class BaseRequestValidatorTests
|
||||
Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
|
||||
}
|
||||
|
||||
|
||||
/* Logic path
|
||||
ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildErrorResult
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_AuthCodeGrantType_DeviceNull_ShouldError(
|
||||
public async Task ValidateAsync_DeviceNotValidated_ShouldLogError(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, default)));
|
||||
|
||||
// 1 -> to pass
|
||||
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
|
||||
_sut.isValid = true;
|
||||
|
||||
context.ValidatedTokenRequest.GrantType = "authorization_code";
|
||||
// 2 -> will result to false with no extra configuration
|
||||
// 3 -> set two factor to be false
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
|
||||
// 4 -> set up device validator to fail
|
||||
requestContext.KnownDevice = false;
|
||||
tokenRequest.GrantType = "password";
|
||||
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// 5 -> not legacy user
|
||||
_userService.IsLegacyUser(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
Assert.Equal("No device information provided.", errorResponse.Message);
|
||||
await _eventService.Received(1)
|
||||
.LogUserEventAsync(context.CustomValidatorRequestContext.User.Id, EventType.User_FailedLogIn);
|
||||
}
|
||||
|
||||
/* Logic path
|
||||
ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildSuccessResultAsync
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_ClientCredentialsGrantType_ShouldSucceed(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult,
|
||||
Device device)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
|
||||
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
|
||||
_sut.isValid = true;
|
||||
|
||||
context.CustomValidatorRequestContext.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(1);
|
||||
_globalSettings.DisableEmailNewDevice = false;
|
||||
|
||||
context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device
|
||||
|
||||
_deviceValidator.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
|
||||
.Returns(device);
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(context.GrantResult.IsError);
|
||||
}
|
||||
|
||||
/* Logic path
|
||||
ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildSuccessResultAsync
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_ClientCredentialsGrantType_ExistingDevice_ShouldSucceed(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult,
|
||||
Device device)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
|
||||
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
|
||||
_sut.isValid = true;
|
||||
|
||||
context.CustomValidatorRequestContext.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(1);
|
||||
_globalSettings.DisableEmailNewDevice = false;
|
||||
|
||||
context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device
|
||||
|
||||
_deviceValidator.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
|
||||
.Returns(device);
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
await _eventService.LogUserEventAsync(
|
||||
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
|
||||
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
|
||||
|
||||
Assert.False(context.GrantResult.IsError);
|
||||
}
|
||||
|
||||
/* Logic path
|
||||
ValidateAsync -> IsLegacyUser -> BuildErrorResultAsync
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_InvalidAuthType_ShouldSetSsoResult(
|
||||
public async Task ValidateAsync_DeviceValidated_ShouldSucceed(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
|
||||
context.ValidatedTokenRequest.Raw["DeviceIdentifier"] = "DeviceIdentifier";
|
||||
context.ValidatedTokenRequest.Raw["DevicePushToken"] = "DevicePushToken";
|
||||
context.ValidatedTokenRequest.Raw["DeviceName"] = "DeviceName";
|
||||
context.ValidatedTokenRequest.Raw["DeviceType"] = "Android"; // This needs to be an actual Type
|
||||
// 1 -> to pass
|
||||
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
|
||||
_sut.isValid = true;
|
||||
|
||||
context.ValidatedTokenRequest.GrantType = "";
|
||||
// 2 -> will result to false with no extra configuration
|
||||
// 3 -> set two factor to be false
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
|
||||
// 4 -> set up device validator to pass
|
||||
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 5 -> not legacy user
|
||||
_userService.IsLegacyUser(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(context.GrantResult.IsError);
|
||||
}
|
||||
|
||||
// Test grantTypes that require SSO when a user is in an organization that requires it
|
||||
[Theory]
|
||||
[BitAutoData("password")]
|
||||
[BitAutoData("webauthn")]
|
||||
[BitAutoData("refresh_token")]
|
||||
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredTrue_ShouldSetSsoResult(
|
||||
string grantType,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
|
||||
_sut.isValid = true;
|
||||
|
||||
context.ValidatedTokenRequest.GrantType = grantType;
|
||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(true));
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
@ -314,6 +276,85 @@ public class BaseRequestValidatorTests
|
||||
Assert.Equal("SSO authentication is required.", errorResponse.Message);
|
||||
}
|
||||
|
||||
// Test grantTypes where SSO would be required but the user is not in an
|
||||
// organization that requires it
|
||||
[Theory]
|
||||
[BitAutoData("password")]
|
||||
[BitAutoData("webauthn")]
|
||||
[BitAutoData("refresh_token")]
|
||||
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredFalse_ShouldSucceed(
|
||||
string grantType,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
|
||||
_sut.isValid = true;
|
||||
|
||||
context.ValidatedTokenRequest.GrantType = grantType;
|
||||
|
||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(false));
|
||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
context.ValidatedTokenRequest.ClientId = "web";
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
await _eventService.Received(1).LogUserEventAsync(
|
||||
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
|
||||
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
|
||||
|
||||
Assert.False(context.GrantResult.IsError);
|
||||
|
||||
}
|
||||
|
||||
// Test the grantTypes where SSO is in progress or not relevant
|
||||
[Theory]
|
||||
[BitAutoData("authorization_code")]
|
||||
[BitAutoData("client_credentials")]
|
||||
public async Task ValidateAsync_GrantTypes_SsoRequiredFalse_ShouldSucceed(
|
||||
string grantType,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
|
||||
_sut.isValid = true;
|
||||
|
||||
context.ValidatedTokenRequest.GrantType = grantType;
|
||||
|
||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
context.ValidatedTokenRequest.ClientId = "web";
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
await _eventService.Received(1).LogUserEventAsync(
|
||||
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
|
||||
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
|
||||
|
||||
Assert.False(context.GrantResult.IsError);
|
||||
}
|
||||
|
||||
/* Logic Path
|
||||
* ValidateAsync -> UserService.IsLegacyUser -> FailAuthForLegacyUserAsync
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
@ -332,6 +373,8 @@ public class BaseRequestValidatorTests
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
@ -339,8 +382,9 @@ public class BaseRequestValidatorTests
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
Assert.Equal($"Encryption key migration is required. Please log in to the web vault at {_globalSettings.BaseServiceUri.VaultWithHash}"
|
||||
, errorResponse.Message);
|
||||
var expectedMessage = $"Encryption key migration is required. Please log in to the web " +
|
||||
$"vault at {_globalSettings.BaseServiceUri.VaultWithHash}";
|
||||
Assert.Equal(expectedMessage, errorResponse.Message);
|
||||
}
|
||||
|
||||
private BaseRequestValidationContextFake CreateContext(
|
||||
@ -367,4 +411,12 @@ public class BaseRequestValidatorTests
|
||||
Substitute.For<IServiceProvider>(),
|
||||
Substitute.For<ILogger<UserManager<User>>>());
|
||||
}
|
||||
|
||||
private void AddValidDeviceToRequest(ValidatedTokenRequest request)
|
||||
{
|
||||
request.Raw["DeviceIdentifier"] = "DeviceIdentifier";
|
||||
request.Raw["DeviceType"] = "Android"; // must be valid device type
|
||||
request.Raw["DeviceName"] = "DeviceName";
|
||||
request.Raw["DevicePushToken"] = "DevicePushToken";
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Identity.IdentityServer;
|
||||
using Bit.Identity.IdentityServer.RequestValidators;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Duende.IdentityServer.Validation;
|
||||
@ -20,6 +23,8 @@ public class DeviceValidatorTests
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly DeviceValidator _sut;
|
||||
|
||||
public DeviceValidatorTests()
|
||||
@ -29,219 +34,550 @@ public class DeviceValidatorTests
|
||||
_globalSettings = new GlobalSettings();
|
||||
_mailService = Substitute.For<IMailService>();
|
||||
_currentContext = Substitute.For<ICurrentContext>();
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_sut = new DeviceValidator(
|
||||
_deviceService,
|
||||
_deviceRepository,
|
||||
_globalSettings,
|
||||
_mailService,
|
||||
_currentContext);
|
||||
_currentContext,
|
||||
_userService,
|
||||
_featureService);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void SaveDeviceAsync_DeviceNull_ShouldReturnNull(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
User user)
|
||||
{
|
||||
// Arrange
|
||||
request.Raw["DeviceIdentifier"] = null;
|
||||
|
||||
// Act
|
||||
var device = await _sut.SaveDeviceAsync(user, request);
|
||||
|
||||
// Assert
|
||||
Assert.Null(device);
|
||||
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void SaveDeviceAsync_UserIsNull_ShouldReturnNull(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
request = AddValidDeviceToRequest(request);
|
||||
|
||||
// Act
|
||||
var device = await _sut.SaveDeviceAsync(null, request);
|
||||
|
||||
// Assert
|
||||
Assert.Null(device);
|
||||
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void SaveDeviceAsync_ExistingUser_NewDevice_ReturnsDevice_SendsEmail(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
User user)
|
||||
{
|
||||
// Arrange
|
||||
request = AddValidDeviceToRequest(request);
|
||||
|
||||
user.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
|
||||
_globalSettings.DisableEmailNewDevice = false;
|
||||
|
||||
// Act
|
||||
var device = await _sut.SaveDeviceAsync(user, request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(device);
|
||||
Assert.Equal(user.Id, device.UserId);
|
||||
Assert.Equal("DeviceIdentifier", device.Identifier);
|
||||
Assert.Equal(DeviceType.Android, device.Type);
|
||||
await _mailService.Received(1).SendNewDeviceLoggedInEmail(
|
||||
user.Email, "Android", Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void SaveDeviceAsync_ExistingUser_NewDevice_ReturnsDevice_SendEmailFalse(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
User user)
|
||||
{
|
||||
// Arrange
|
||||
request = AddValidDeviceToRequest(request);
|
||||
|
||||
user.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
|
||||
_globalSettings.DisableEmailNewDevice = true;
|
||||
|
||||
// Act
|
||||
var device = await _sut.SaveDeviceAsync(user, request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(device);
|
||||
Assert.Equal(user.Id, device.UserId);
|
||||
Assert.Equal("DeviceIdentifier", device.Identifier);
|
||||
Assert.Equal(DeviceType.Android, device.Type);
|
||||
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
|
||||
user.Email, "Android", Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void SaveDeviceAsync_DeviceIsKnown_ShouldReturnDevice(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
User user,
|
||||
[Theory, BitAutoData]
|
||||
public async void GetKnownDeviceAsync_UserNull_ReturnsFalse(
|
||||
Device device)
|
||||
{
|
||||
// Arrange
|
||||
request = AddValidDeviceToRequest(request);
|
||||
|
||||
device.UserId = user.Id;
|
||||
device.Identifier = "DeviceIdentifier";
|
||||
device.Type = DeviceType.Android;
|
||||
device.Name = "DeviceName";
|
||||
device.PushToken = "DevicePushToken";
|
||||
_deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id).Returns(device);
|
||||
// AutoData arrages
|
||||
|
||||
// Act
|
||||
var resultDevice = await _sut.SaveDeviceAsync(user, request);
|
||||
var result = await _sut.GetKnownDeviceAsync(null, device);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(device, resultDevice);
|
||||
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void SaveDeviceAsync_NewUser_DeviceUnknown_ShouldSaveDevice_NoEmail(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
User user)
|
||||
{
|
||||
// Arrange
|
||||
request = AddValidDeviceToRequest(request);
|
||||
user.CreationDate = DateTime.UtcNow;
|
||||
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>()).Returns(null as Device);
|
||||
|
||||
// Act
|
||||
var device = await _sut.SaveDeviceAsync(user, request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(device);
|
||||
Assert.Equal(user.Id, device.UserId);
|
||||
Assert.Equal("DeviceIdentifier", device.Identifier);
|
||||
Assert.Equal(DeviceType.Android, device.Type);
|
||||
await _deviceService.Received(1).SaveAsync(device);
|
||||
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void KnownDeviceAsync_UserNull_ReturnsFalse(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
request = AddValidDeviceToRequest(request);
|
||||
|
||||
// Act
|
||||
var result = await _sut.KnownDeviceAsync(null, request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void KnownDeviceAsync_DeviceNull_ReturnsFalse(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
[Theory, BitAutoData]
|
||||
public async void GetKnownDeviceAsync_DeviceNull_ReturnsFalse(
|
||||
User user)
|
||||
{
|
||||
// Arrange
|
||||
// Device raw data is null which will cause the device to be null
|
||||
|
||||
// Act
|
||||
var result = await _sut.KnownDeviceAsync(user, request);
|
||||
var result = await _sut.GetKnownDeviceAsync(user, null);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void KnownDeviceAsync_DeviceNotInDatabase_ReturnsFalse(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
User user)
|
||||
[Theory, BitAutoData]
|
||||
public async void GetKnownDeviceAsync_DeviceNotInDatabase_ReturnsFalse(
|
||||
User user,
|
||||
Device device)
|
||||
{
|
||||
// Arrange
|
||||
request = AddValidDeviceToRequest(request);
|
||||
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>())
|
||||
.Returns(null as Device);
|
||||
// Act
|
||||
var result = await _sut.KnownDeviceAsync(user, request);
|
||||
var result = await _sut.GetKnownDeviceAsync(user, device);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async void KnownDeviceAsync_UserAndDeviceValid_ReturnsTrue(
|
||||
[Theory, BitAutoData]
|
||||
public async void GetKnownDeviceAsync_UserAndDeviceValid_ReturnsTrue(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
|
||||
User user,
|
||||
Device device)
|
||||
{
|
||||
// Arrange
|
||||
request = AddValidDeviceToRequest(request);
|
||||
AddValidDeviceToRequest(request);
|
||||
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>())
|
||||
.Returns(device);
|
||||
// Act
|
||||
var result = await _sut.KnownDeviceAsync(user, request);
|
||||
var result = await _sut.GetKnownDeviceAsync(user, device);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("not null", "Android", "")]
|
||||
[BitAutoData("not null", "", "not null")]
|
||||
[BitAutoData("", "Android", "not null")]
|
||||
public void GetDeviceFromRequest_RawDeviceInfoNull_ReturnsNull(
|
||||
string deviceIdentifier,
|
||||
string deviceType,
|
||||
string deviceName,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
request.Raw["DeviceIdentifier"] = deviceIdentifier;
|
||||
request.Raw["DeviceType"] = deviceType;
|
||||
request.Raw["DeviceName"] = deviceName;
|
||||
|
||||
// Act
|
||||
var result = DeviceValidator.GetDeviceFromRequest(request);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void GetDeviceFromRequest_RawDeviceInfoValid_ReturnsDevice(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
AddValidDeviceToRequest(request);
|
||||
|
||||
// Act
|
||||
var result = DeviceValidator.GetDeviceFromRequest(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("DeviceIdentifier", result.Identifier);
|
||||
Assert.Equal("DeviceName", result.Name);
|
||||
Assert.Equal(DeviceType.Android, result.Type);
|
||||
Assert.Equal("DevicePushToken", result.PushToken);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_DeviceNull_ContextModified_ReturnsFalse(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
context.Device = null;
|
||||
|
||||
// Act
|
||||
Assert.NotNull(context.User);
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
|
||||
|
||||
Assert.False(result);
|
||||
Assert.NotNull(context.CustomResponse["ErrorModel"]);
|
||||
var expectedErrorModel = new ErrorResponseModel("no device information provided");
|
||||
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
|
||||
Assert.Equal(expectedErrorModel.Message, actualResponse.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_RequestDeviceKnown_ContextDeviceModified_ReturnsTrue(
|
||||
Device device,
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
context.Device = null;
|
||||
AddValidDeviceToRequest(request);
|
||||
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>())
|
||||
.Returns(device);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
|
||||
|
||||
Assert.True(result);
|
||||
Assert.False(context.CustomResponse.ContainsKey("ErrorModel"));
|
||||
Assert.NotNull(context.Device);
|
||||
Assert.Equal(context.Device, device);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_ContextDeviceKnown_ContextDeviceModified_ReturnsTrue(
|
||||
Device databaseDevice,
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>())
|
||||
.Returns(databaseDevice);
|
||||
// we want to show that the context device is updated when the device is known
|
||||
Assert.NotEqual(context.Device, databaseDevice);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
|
||||
|
||||
Assert.True(result);
|
||||
Assert.False(context.CustomResponse.ContainsKey("ErrorModel"));
|
||||
Assert.Equal(context.Device, databaseDevice);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_SendsEmail_ReturnsTrue(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
AddValidDeviceToRequest(request);
|
||||
_globalSettings.DisableEmailNewDevice = false;
|
||||
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
|
||||
.Returns(null as Device);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
|
||||
.Returns(false);
|
||||
// set user creation to more than 10 minutes ago
|
||||
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
await _mailService.Received(1).SendNewDeviceLoggedInEmail(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_NewUser_DoesNotSendEmail_ReturnsTrue(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
AddValidDeviceToRequest(request);
|
||||
_globalSettings.DisableEmailNewDevice = false;
|
||||
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
|
||||
.Returns(null as Device);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
|
||||
.Returns(false);
|
||||
// set user creation to less than 10 minutes ago
|
||||
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(9);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
await _mailService.Received(0).SendNewDeviceLoggedInEmail(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_DisableEmailTrue_DoesNotSendEmail_ReturnsTrue(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
AddValidDeviceToRequest(request);
|
||||
_globalSettings.DisableEmailNewDevice = true;
|
||||
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
|
||||
.Returns(null as Device);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
await _mailService.Received(0).SendNewDeviceLoggedInEmail(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("webauthn")]
|
||||
[BitAutoData("refresh_token")]
|
||||
[BitAutoData("authorization_code")]
|
||||
[BitAutoData("client_credentials")]
|
||||
public async void ValidateRequestDeviceAsync_GrantTypeNotPassword_SavesDevice_ReturnsTrue(
|
||||
string grantType,
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
AddValidDeviceToRequest(request);
|
||||
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
|
||||
.Returns(null as Device);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
|
||||
.Returns(true);
|
||||
|
||||
request.GrantType = grantType;
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_IsAuthRequest_SavesDevice_ReturnsTrue(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
AddValidDeviceToRequest(request);
|
||||
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
|
||||
.Returns(null as Device);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
|
||||
.Returns(true);
|
||||
|
||||
request.Raw.Add("AuthRequest", "authRequest");
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_TwoFactorRequired_SavesDevice_ReturnsTrue(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
AddValidDeviceToRequest(request);
|
||||
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
|
||||
.Returns(null as Device);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
|
||||
.Returns(true);
|
||||
|
||||
context.TwoFactorRequired = true;
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_SsoRequired_SavesDevice_ReturnsTrue(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
AddValidDeviceToRequest(request);
|
||||
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
|
||||
.Returns(null as Device);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
|
||||
.Returns(true);
|
||||
|
||||
context.SsoRequired = true;
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void HandleNewDeviceVerificationAsync_UserNull_ContextModified_ReturnsInvalidUser(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
|
||||
_globalSettings.EnableNewDeviceVerification = true;
|
||||
|
||||
context.User = null;
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
|
||||
|
||||
Assert.False(result);
|
||||
Assert.NotNull(context.CustomResponse["ErrorModel"]);
|
||||
// PM-13340: The error message should be "invalid user" instead of "no device information provided"
|
||||
var expectedErrorMessage = "no device information provided";
|
||||
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
|
||||
Assert.Equal(expectedErrorMessage, actualResponse.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void HandleNewDeviceVerificationAsync_NewDeviceOtpValid_ReturnsSuccess(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
|
||||
_globalSettings.EnableNewDeviceVerification = true;
|
||||
|
||||
var newDeviceOtp = "123456";
|
||||
request.Raw.Add("NewDeviceOtp", newDeviceOtp);
|
||||
|
||||
_userService.VerifyOTPAsync(context.User, newDeviceOtp).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _userService.Received(0).SendOTPAsync(context.User);
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.False(context.CustomResponse.ContainsKey("ErrorModel"));
|
||||
Assert.Equal(context.User.Id, context.Device.UserId);
|
||||
Assert.NotNull(context.Device);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("")]
|
||||
[BitAutoData("123456")]
|
||||
public async void HandleNewDeviceVerificationAsync_NewDeviceOtpInvalid_ReturnsInvalidNewDeviceOtp(
|
||||
string newDeviceOtp,
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
|
||||
_globalSettings.EnableNewDeviceVerification = true;
|
||||
|
||||
request.Raw.Add("NewDeviceOtp", newDeviceOtp);
|
||||
|
||||
_userService.VerifyOTPAsync(context.User, newDeviceOtp).Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _userService.DidNotReceive().SendOTPAsync(Arg.Any<User>());
|
||||
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
|
||||
|
||||
Assert.False(result);
|
||||
Assert.NotNull(context.CustomResponse["ErrorModel"]);
|
||||
var expectedErrorMessage = "invalid new device otp";
|
||||
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
|
||||
Assert.Equal(expectedErrorMessage, actualResponse.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void HandleNewDeviceVerificationAsync_UserHasNoDevices_ReturnsSuccess(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
|
||||
_globalSettings.EnableNewDeviceVerification = true;
|
||||
_deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _userService.Received(0).VerifyOTPAsync(Arg.Any<User>(), Arg.Any<string>());
|
||||
await _userService.Received(0).SendOTPAsync(Arg.Any<User>());
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.False(context.CustomResponse.ContainsKey("ErrorModel"));
|
||||
Assert.Equal(context.User.Id, context.Device.UserId);
|
||||
Assert.NotNull(context.Device);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void HandleNewDeviceVerificationAsync_NewDeviceOtpEmpty_UserHasDevices_ReturnsNewDeviceVerificationRequired(
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
|
||||
_globalSettings.EnableNewDeviceVerification = true;
|
||||
_deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([new Device()]);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _userService.Received(1).SendOTPAsync(context.User);
|
||||
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
|
||||
|
||||
Assert.False(result);
|
||||
Assert.NotNull(context.CustomResponse["ErrorModel"]);
|
||||
var expectedErrorMessage = "new device verification required";
|
||||
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
|
||||
Assert.Equal(expectedErrorMessage, actualResponse.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void NewDeviceOtpRequest_NewDeviceOtpNull_ReturnsFalse(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
// Autodata arranges
|
||||
|
||||
// Act
|
||||
var result = DeviceValidator.NewDeviceOtpRequest(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void NewDeviceOtpRequest_NewDeviceOtpNotNull_ReturnsTrue(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
request.Raw["NewDeviceOtp"] = "123456";
|
||||
|
||||
// Act
|
||||
var result = DeviceValidator.NewDeviceOtpRequest(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
private ValidatedTokenRequest AddValidDeviceToRequest(ValidatedTokenRequest request)
|
||||
private static void AddValidDeviceToRequest(ValidatedTokenRequest request)
|
||||
{
|
||||
request.Raw["DeviceIdentifier"] = "DeviceIdentifier";
|
||||
request.Raw["DeviceType"] = "Android";
|
||||
request.Raw["DeviceType"] = "Android"; // must be valid device type
|
||||
request.Raw["DeviceName"] = "DeviceName";
|
||||
request.Raw["DevicePushToken"] = "DevicePushToken";
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the request context to facilitate testing the HandleNewDeviceVerificationAsync method.
|
||||
/// </summary>
|
||||
/// <param name="context">test context</param>
|
||||
/// <param name="request">test request</param>
|
||||
private static void ArrangeForHandleNewDeviceVerificationTest(
|
||||
CustomValidatorRequestContext context,
|
||||
ValidatedTokenRequest request)
|
||||
{
|
||||
context.KnownDevice = false;
|
||||
request.GrantType = "password";
|
||||
context.TwoFactorRequired = false;
|
||||
context.SsoRequired = false;
|
||||
}
|
||||
}
|
||||
|
@ -123,6 +123,11 @@ IBaseRequestValidatorTestWrapper
|
||||
Dictionary<string, object> customResponse)
|
||||
{ }
|
||||
|
||||
protected override void SetValidationErrorResult(
|
||||
BaseRequestValidationContextFake context,
|
||||
CustomValidatorRequestContext requestContext)
|
||||
{ }
|
||||
|
||||
protected override Task<bool> ValidateContextAsync(
|
||||
BaseRequestValidationContextFake context,
|
||||
CustomValidatorRequestContext validatorContext)
|
||||
|
Loading…
Reference in New Issue
Block a user