diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs
index 8174d7d3643a..89851fce232a 100644
--- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs
+++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs
@@ -329,7 +329,7 @@ private void ValidateOpenRegistrationAllowed()
{
// 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);
diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs
index 793b6ac1c16a..2ececb9658ba 100644
--- a/src/Core/Settings/GlobalSettings.cs
+++ b/src/Core/Settings/GlobalSettings.cs
@@ -41,6 +41,7 @@ public virtual string LicenseDirectory
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 string ConnectionString
public bool EnableSendTracing { get; set; } = false;
///
/// 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.
- ///
+ ///
///
public DateTime? RegistrationStartDate { get; set; }
///
/// 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.
///
public DateTime? RegistrationEndDate { get; set; }
@@ -454,7 +455,7 @@ public class NotificationHubPoolSettings
{
///
/// 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.
///
public List NotificationHubs { get; set; } = new();
diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs
index d91d4b8c3d01..02d151ed9558 100644
--- a/src/Core/Settings/IGlobalSettings.cs
+++ b/src/Core/Settings/IGlobalSettings.cs
@@ -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; }
diff --git a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs
index a3485bfb13a0..bce460c5c4fb 100644
--- a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs
+++ b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs
@@ -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; }
+ ///
+ /// 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.
+ ///
+ public Device Device { get; set; }
+ ///
+ /// 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.
+ ///
public bool KnownDevice { get; set; }
+ ///
+ /// This communicates whether or not two factor is required for the user to authenticate.
+ ///
+ public bool TwoFactorRequired { get; set; } = false;
+ ///
+ /// This communicates whether or not SSO is required for the user to authenticate.
+ ///
+ public bool SsoRequired { get; set; } = false;
+ ///
+ /// 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.
+ ///
+ public ValidationResult ValidationErrorResult { get; set; }
+ ///
+ /// 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.
+ ///
+ public Dictionary CustomResponse { get; set; }
public CaptchaResponse CaptchaResponse { get; set; }
}
diff --git a/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs b/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs
new file mode 100644
index 000000000000..45c901e30612
--- /dev/null
+++ b/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs
@@ -0,0 +1,10 @@
+namespace Bit.Identity.IdentityServer.Enums;
+
+public enum DeviceValidationResultType : byte
+{
+ Success = 0,
+ InvalidUser = 1,
+ InvalidNewDeviceOtp = 2,
+ NewDeviceVerificationRequired = 3,
+ NoDeviceInformationProvided = 4
+}
diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
index 185d32a7f2bc..78c00f86d5e0 100644
--- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
@@ -77,37 +77,51 @@ public BaseRequestValidator(
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);
+ // 2. Does this user belong to an organization that requires SSO
+ validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
+ if (validatorContext.SsoRequired)
+ {
+ SetSsoResult(context,
+ new Dictionary
+ {
+ { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
+ });
+ return;
+ }
- if (isTwoFactorRequired)
+ // 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)
{
- // 2FA required and not provided response
+ 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 @@ protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
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 @@ protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
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;
}
+
+ // 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;
+ }
}
- else
+
+ // 4. Check if the user is logging in from a new device
+ var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
+ if (!deviceValid)
{
- validTwoFactorRequest = false;
- twoFactorRemember = false;
+ SetValidationErrorResult(context, validatorContext);
+ await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
+ return;
}
- // Force legacy users to the web for migration
+ // 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 @@ protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
}
}
- 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
- {
- { "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 @@ protected async Task BuildSuccessResultAsync(User user, T context, Device device
await SetSuccessResult(context, user, claims, customResponse);
}
+ ///
+ /// 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.
+ ///
+ /// Error message for the error result
+ /// bool that controls how the error is logged
+ /// used to set the error result in the current validator
+ /// used to associate the failed login with a user
+ /// void
+ [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 @@ await _eventService.LogUserEventAsync(user.Id,
new Dictionary { { "ErrorModel", new ErrorResponseModel(message) } });
}
- protected abstract void SetTwoFactorResult(T context, Dictionary customResponse);
+ 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 customResponse);
+ [Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetSsoResult(T context, Dictionary customResponse);
+ [Obsolete("Consider using SetValidationErrorResult instead.")]
+ protected abstract void SetErrorResult(T context, Dictionary customResponse);
+ ///
+ /// This consumes the ValidationErrorResult property in the CustomValidatorRequestContext and sets
+ /// it appropriately in the response object for the token and grant validators.
+ ///
+ /// The current grant or token context
+ /// The modified request context containing material used to build the response object
+ protected abstract void SetValidationErrorResult(T context, CustomValidatorRequestContext requestContext);
protected abstract Task SetSuccessResult(T context, User user, List claims,
Dictionary customResponse);
- protected abstract void SetErrorResult(T context, Dictionary customResponse);
protected abstract ClaimsPrincipal GetSubject(T context);
///
/// 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.
///
/// user trying to login
/// magic string identifying the grant type requested
- ///
- private async Task IsValidAuthTypeAsync(User user, string grantType)
+ /// true if sso required; false if not required or already in process
+ private async Task 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;
+ // Already using SSO to authenticate, or logging-in via api key to skip SSO requirement
+ // allow to authenticate successfully
+ return false;
}
// Check if user belongs to any organization with an active SSO policy
- var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
+ var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(
+ user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser)
{
- return false;
+ return true;
}
- // Default - continue validation process
- return true;
+ // Default - SSO is not required
+ return false;
}
private async Task ResetFailedAuthDetailsAsync(User user)
@@ -350,7 +411,7 @@ private async Task GetMasterPasswordPolicy(Us
var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
.ToList();
- if (!orgs.Any())
+ if (orgs.Count == 0)
{
return null;
}
diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
index c826243f88da..fb7b129b09af 100644
--- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
@@ -89,8 +89,7 @@ public async Task ValidateAsync(CustomTokenRequestValidationContext context)
}
return;
}
- await ValidateAsync(context, context.Result.ValidatedRequest,
- new CustomValidatorRequestContext { KnownDevice = true });
+ await ValidateAsync(context, context.Result.ValidatedRequest, new CustomValidatorRequestContext { });
}
protected async override Task ValidateContextAsync(CustomTokenRequestValidationContext context,
@@ -162,6 +161,7 @@ protected override Task SetSuccessResult(CustomTokenRequestValidationContext con
return context.Result.ValidatedRequest.Subject;
}
+ [Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context,
Dictionary customResponse)
{
@@ -172,16 +172,18 @@ protected override void SetTwoFactorResult(CustomTokenRequestValidationContext c
context.Result.CustomResponse = customResponse;
}
+ [Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetSsoResult(CustomTokenRequestValidationContext context,
Dictionary 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 customResponse)
{
@@ -190,4 +192,14 @@ protected override void SetErrorResult(CustomTokenRequestValidationContext conte
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;
+ }
}
diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
index db9368cf469e..2a048bcb2aab 100644
--- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
@@ -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
-{
- ///
- /// Save a device to the database. If the device is already known, it will be returned.
- ///
- /// The user is assumed NOT null, still going to check though
- /// Duende Validated Request that contains the data to create the device object
- /// Returns null if user or device is malformed; The existing device if already in DB; a new device login
- Task SaveDeviceAsync(User user, ValidatedTokenRequest request);
- ///
- /// Check if a device is known to the user.
- ///
- /// current user trying to authenticate
- /// contains raw information that is parsed about the device
- /// true if the device is known, false if it is not
- Task 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;
- ///
- /// Save a device to the database. If the device is already known, it will be returned.
- ///
- /// The user is assumed NOT null, still going to check though
- /// Duende Validated Request that contains the data to create the device object
- /// Returns null if user or device is malformed; The existing device if already in DB; a new device login
- public async Task SaveDeviceAsync(User user, ValidatedTokenRequest request)
+ public async Task 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)
+ {
+ (context.ValidationErrorResult, context.CustomResponse) =
+ BuildDeviceErrorResult(DeviceValidationResultType.NoDeviceInformationProvided);
+ return false;
+ }
+
+ // 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)
{
- var existingDevice = await GetKnownDeviceAsync(user, device);
- if (existingDevice == null)
+ // 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)
{
- device.UserId = user.Id;
- await _deviceService.SaveAsync(device);
+ (context.ValidationErrorResult, context.CustomResponse) =
+ BuildDeviceErrorResult(validationResult);
+ if (validationResult == DeviceValidationResultType.NewDeviceVerificationRequired)
+ {
+ await _userService.SendOTPAsync(context.User);
+ }
+ return false;
+ }
+ }
- // 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))
+ // 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()?.GetName();
+ if (!_globalSettings.DisableEmailNewDevice)
{
- var deviceType = device.Type.GetType().GetMember(device.Type.ToString())
- .FirstOrDefault()?.GetCustomAttribute()?.GetName();
- if (!_globalSettings.DisableEmailNewDevice)
- {
- await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
- _currentContext.IpAddress);
- }
+ await _mailService.SendNewDeviceLoggedInEmail(context.User.Email, deviceType, now,
+ _currentContext.IpAddress);
}
- return device;
}
- return existingDevice;
}
- return null;
+ return true;
}
- public async Task KnownDeviceAsync(User user, ValidatedTokenRequest request) =>
- (await GetKnownDeviceAsync(user, GetDeviceFromRequest(request))) != default;
+ ///
+ /// Checks the if the requesting deice requires new device verification otherwise saves the device to the database
+ ///
+ /// user attempting to authenticate
+ /// The Request is used to check for the NewDeviceOtp and for the raw device data
+ /// returns deviceValtaionResultType
+ private async Task HandleNewDeviceVerificationAsync(User user, ValidatedRequest request)
+ {
+ // currently unreachable due to backward compatibility
+ // PM-13340: will address this
+ if (user == null)
+ {
+ return DeviceValidationResultType.InvalidUser;
+ }
+
+ // 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;
+ }
- private async Task GetKnownDeviceAsync(User user, Device device)
+ public async Task 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 @@ private static Device GetDeviceFromRequest(ValidatedRequest request)
PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken
};
}
+
+ ///
+ /// Checks request for the NewDeviceOtp field to determine if a new device verification is required.
+ ///
+ ///
+ ///
+ public static bool NewDeviceOtpRequest(ValidatedRequest request)
+ {
+ return !string.IsNullOrEmpty(request.Raw["NewDeviceOtp"]?.ToString());
+ }
+
+ ///
+ /// This builds builds the error result for the various grant and token validators. The Success type is not used here.
+ ///
+ /// DeviceValidationResultType that is an error, success type is not used.
+ /// validation result used by grant and token validators, and the custom response for either Grant or Token response objects.
+ private static (Duende.IdentityServer.Validation.ValidationResult, Dictionary) BuildDeviceErrorResult(DeviceValidationResultType errorType)
+ {
+ var result = new Duende.IdentityServer.Validation.ValidationResult
+ {
+ IsError = true,
+ Error = "device_error",
+ };
+ var customResponse = new Dictionary();
+ 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);
+ }
}
diff --git a/src/Identity/IdentityServer/RequestValidators/IDeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/IDeviceValidator.cs
new file mode 100644
index 000000000000..0bff7e4fab59
--- /dev/null
+++ b/src/Identity/IdentityServer/RequestValidators/IDeviceValidator.cs
@@ -0,0 +1,24 @@
+using Bit.Core.Entities;
+using Duende.IdentityServer.Validation;
+
+namespace Bit.Identity.IdentityServer.RequestValidators;
+
+public interface IDeviceValidator
+{
+ ///
+ /// 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.
+ ///
+ /// user attempting to authenticate
+ /// current instance of Bitwarden the user is interacting with
+ /// null or Device
+ Task GetKnownDeviceAsync(User user, Device device);
+
+ ///
+ /// Validate the requesting device. Modifies the ValidatorRequestContext with error result if any.
+ ///
+ /// The Request is used to check for the NewDeviceOtp and for the raw device data
+ /// Contains two factor and sso context that are important for decisions on new device verification
+ /// returns true if device is valid and no other action required; if false modifies the context with an error result to be returned;
+ Task ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context);
+}
diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs
index f072a6417725..852bf27e408b 100644
--- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs
@@ -75,11 +75,16 @@ public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
}
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 @@ protected override Task SetSuccessResult(ResourceOwnerPasswordValidationContext
return Task.CompletedTask;
}
+ [Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetTwoFactorResult(ResourceOwnerPasswordValidationContext context,
Dictionary customResponse)
{
@@ -163,6 +169,7 @@ protected override void SetTwoFactorResult(ResourceOwnerPasswordValidationContex
customResponse);
}
+ [Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetSsoResult(ResourceOwnerPasswordValidationContext context,
Dictionary customResponse)
{
@@ -170,12 +177,25 @@ protected override void SetSsoResult(ResourceOwnerPasswordValidationContext cont
customResponse);
}
+ [Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context,
Dictionary 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 @@ protected override ClaimsPrincipal GetSubject(ResourceOwnerPasswordValidationCon
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;
}
diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs
index 515dca7828b0..499c22ad8961 100644
--- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs
@@ -91,15 +91,9 @@ public async Task ValidateAsync(ExtensionGrantValidationContext context)
}
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 ValidateContextAsync(ExtensionGrantValidationContext context,
@@ -128,6 +122,7 @@ protected override ClaimsPrincipal GetSubject(ExtensionGrantValidationContext co
return context.Result.Subject;
}
+ [Obsolete("Consider using SetValidationErrorResult instead.")]
protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
Dictionary customResponse)
{
@@ -135,6 +130,7 @@ protected override void SetTwoFactorResult(ExtensionGrantValidationContext conte
customResponse);
}
+ [Obsolete("Consider using SetValidationErrorResult instead.")]
protected override void SetSsoResult(ExtensionGrantValidationContext context,
Dictionary customResponse)
{
@@ -142,9 +138,21 @@ protected override void SetSsoResult(ExtensionGrantValidationContext context,
customResponse);
}
- protected override void SetErrorResult(ExtensionGrantValidationContext context,
- Dictionary customResponse)
+ [Obsolete("Consider using SetValidationErrorResult instead.")]
+ protected override void SetErrorResult(ExtensionGrantValidationContext context, Dictionary 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
+ };
+ }
}
diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs
index 703faed48cf4..4bec8d8167ad 100644
--- a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs
+++ b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs
@@ -5,14 +5,11 @@
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 @@ This doesn't build confidence in the tests.
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(sub =>
- {
- sub.SaveDeviceAsync(Arg.Any(), Arg.Any())
- .Returns(null as Device);
- });
-
- // Add User
- await factory.RegisterAsync(new RegisterRequestModel
- {
- Email = DefaultUsername,
- MasterPasswordHash = DefaultPassword
- });
- var userManager = factory.GetService>();
- 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(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 @@ private FormUrlEncodedContent GetFormUrlEncodedContent(
});
}
+ private FormUrlEncodedContent GetDefaultFormUrlEncodedContentWithoutDevice()
+ {
+ return new FormUrlEncodedContent(new Dictionary
+ {
+ { "scope", "api offline_access" },
+ { "client_id", "web" },
+ { "grant_type", "password" },
+ { "username", DefaultUsername },
+ { "password", DefaultPassword },
+ });
+ }
+
private static string DeviceTypeAsString(DeviceType deviceType)
{
return ((int)deviceType).ToString();
diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs
index d0372202ad73..02b698241974 100644
--- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs
+++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs
@@ -22,7 +22,6 @@
using Xunit;
using AuthFixtures = Bit.Identity.Test.AutoFixture;
-
namespace Bit.Identity.Test.IdentityServer;
public class BaseRequestValidatorTests
@@ -82,10 +81,10 @@ public 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 @@ await _eventService.Received(1)
}
/* 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 async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResul
}
/* 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,63 +176,69 @@ await _mailService.Received(1)
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(), Arg.Any())
- .Returns(Task.FromResult(new Tuple(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(), tokenRequest)
+ .Returns(Task.FromResult(new Tuple(false, null)));
+
+ // 4 -> set up device validator to fail
+ requestContext.KnownDevice = false;
+ tokenRequest.GrantType = "password";
+ _deviceValidator.ValidateRequestDeviceAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(false));
+
+ // 5 -> not legacy user
+ _userService.IsLegacyUser(Arg.Any())
+ .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(
+ public async Task ValidateAsync_DeviceValidated_ShouldSucceed(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
- GrantValidationResult grantResult,
- Device device)
+ GrantValidationResult grantResult)
{
// Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult);
- _twoFactorAuthenticationValidator
- .RequiresTwoFactorAsync(Arg.Any(), Arg.Any())
- .Returns(Task.FromResult(new Tuple(false, null)));
-
+ // 1 -> to pass
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
- context.CustomValidatorRequestContext.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(1);
- _globalSettings.DisableEmailNewDevice = false;
+ // 2 -> will result to false with no extra configuration
+ // 3 -> set two factor to be false
+ _twoFactorAuthenticationValidator
+ .RequiresTwoFactorAsync(Arg.Any(), tokenRequest)
+ .Returns(Task.FromResult(new Tuple(false, null)));
+
+ // 4 -> set up device validator to pass
+ _deviceValidator.ValidateRequestDeviceAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(true));
- context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device
+ // 5 -> not legacy user
+ _userService.IsLegacyUser(Arg.Any())
+ .Returns(false);
- _deviceValidator.SaveDeviceAsync(Arg.Any(), Arg.Any())
- .Returns(device);
// Act
await _sut.ValidateAsync(context);
@@ -241,79 +246,115 @@ public async Task ValidateAsync_ClientCredentialsGrantType_ShouldSucceed(
Assert.False(context.GrantResult.IsError);
}
- /* Logic path
- ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildSuccessResultAsync
- */
- [Theory, BitAutoData]
- public async Task ValidateAsync_ClientCredentialsGrantType_ExistingDevice_ShouldSucceed(
+ // 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,
- Device device)
+ GrantValidationResult grantResult)
{
// 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 = grantType;
+ _policyService.AnyPoliciesApplicableToUserAsync(
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
+ .Returns(Task.FromResult(true));
- context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device
+ // Act
+ await _sut.ValidateAsync(context);
- _deviceValidator.SaveDeviceAsync(Arg.Any(), Arg.Any())
- .Returns(device);
- _twoFactorAuthenticationValidator
- .RequiresTwoFactorAsync(Arg.Any(), Arg.Any())
+ // Assert
+ Assert.True(context.GrantResult.IsError);
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+ 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(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
+ .Returns(Task.FromResult(false));
+ _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple(false, null)));
+ _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
+ .Returns(Task.FromResult(true));
+ context.ValidatedTokenRequest.ClientId = "web";
+
// Act
await _sut.ValidateAsync(context);
// Assert
- await _eventService.LogUserEventAsync(
+ await _eventService.Received(1).LogUserEventAsync(
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
await _userRepository.Received(1).ReplaceAsync(Arg.Any());
Assert.False(context.GrantResult.IsError);
+
}
- /* Logic path
- ValidateAsync -> IsLegacyUser -> BuildErrorResultAsync
- */
- [Theory, BitAutoData]
- public async Task ValidateAsync_InvalidAuthType_ShouldSetSsoResult(
+ // 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.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
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
- context.ValidatedTokenRequest.GrantType = "";
+ context.ValidatedTokenRequest.GrantType = grantType;
- _policyService.AnyPoliciesApplicableToUserAsync(
- Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
- .Returns(Task.FromResult(true));
- _twoFactorAuthenticationValidator
- .RequiresTwoFactorAsync(Arg.Any(), Arg.Any())
+ _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple(false, null)));
+ _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
+ .Returns(Task.FromResult(true));
+ context.ValidatedTokenRequest.ClientId = "web";
+
// Act
await _sut.ValidateAsync(context);
// Assert
- Assert.True(context.GrantResult.IsError);
- var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
- Assert.Equal("SSO authentication is required.", errorResponse.Message);
+ await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
+ await _eventService.Received(1).LogUserEventAsync(
+ context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
+ await _userRepository.Received(1).ReplaceAsync(Arg.Any());
+
+ 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 async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any(), Arg.Any())
.Returns(Task.FromResult(new Tuple(false, null)));
+ _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
+ .Returns(Task.FromResult(true));
// Act
await _sut.ValidateAsync(context);
@@ -339,8 +382,9 @@ public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
// 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 @@ private UserManager SubstituteUserManager()
Substitute.For(),
Substitute.For>>());
}
+
+ 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";
+ }
}
diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
index 2db792c9363c..304715b68cb1 100644
--- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
+++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
@@ -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 DeviceValidatorTests()
_globalSettings = new GlobalSettings();
_mailService = Substitute.For();
_currentContext = Substitute.For();
+ _userService = Substitute.For();
+ _featureService = Substitute.For();
_sut = new DeviceValidator(
_deviceService,
_deviceRepository,
_globalSettings,
_mailService,
- _currentContext);
+ _currentContext,
+ _userService,
+ _featureService);
}
- [Theory]
- [BitAutoData]
- public async void SaveDeviceAsync_DeviceNull_ShouldReturnNull(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
+ [Theory, BitAutoData]
+ public async void GetKnownDeviceAsync_UserNull_ReturnsFalse(
+ Device device)
+ {
+ // Arrange
+ // AutoData arrages
+
+ // Act
+ var result = await _sut.GetKnownDeviceAsync(null, device);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Theory, BitAutoData]
+ public async void GetKnownDeviceAsync_DeviceNull_ReturnsFalse(
User user)
{
// Arrange
- request.Raw["DeviceIdentifier"] = null;
+ // Device raw data is null which will cause the device to be null
// Act
- var device = await _sut.SaveDeviceAsync(user, request);
+ var result = await _sut.GetKnownDeviceAsync(user, null);
// Assert
- Assert.Null(device);
- await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
- Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ Assert.Null(result);
+ }
+
+ [Theory, BitAutoData]
+ public async void GetKnownDeviceAsync_DeviceNotInDatabase_ReturnsFalse(
+ User user,
+ Device device)
+ {
+ // Arrange
+ _deviceRepository.GetByIdentifierAsync(Arg.Any(), Arg.Any())
+ .Returns(null as Device);
+ // Act
+ var result = await _sut.GetKnownDeviceAsync(user, device);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Theory, BitAutoData]
+ public async void GetKnownDeviceAsync_UserAndDeviceValid_ReturnsTrue(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
+ User user,
+ Device device)
+ {
+ // Arrange
+ AddValidDeviceToRequest(request);
+ _deviceRepository.GetByIdentifierAsync(Arg.Any(), Arg.Any())
+ .Returns(device);
+ // Act
+ var result = await _sut.GetKnownDeviceAsync(user, device);
+
+ // Assert
+ Assert.NotNull(result);
}
[Theory]
- [BitAutoData]
- public async void SaveDeviceAsync_UserIsNull_ShouldReturnNull(
+ [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 = AddValidDeviceToRequest(request);
+ request.Raw["DeviceIdentifier"] = deviceIdentifier;
+ request.Raw["DeviceType"] = deviceType;
+ request.Raw["DeviceName"] = deviceName;
// Act
- var device = await _sut.SaveDeviceAsync(null, request);
+ var result = DeviceValidator.GetDeviceFromRequest(request);
// Assert
- Assert.Null(device);
- await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
- Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ Assert.Null(result);
}
- [Theory]
- [BitAutoData]
- public async void SaveDeviceAsync_ExistingUser_NewDevice_ReturnsDevice_SendsEmail(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
- User user)
+ [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
- request = AddValidDeviceToRequest(request);
+ context.KnownDevice = false;
+ context.Device = null;
- user.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
+ // Act
+ Assert.NotNull(context.User);
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
+
+ // Assert
+ await _deviceService.Received(0).SaveAsync(Arg.Any());
+
+ 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(), Arg.Any())
+ .Returns(device);
+
+ // Act
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
+
+ // Assert
+ await _deviceService.Received(0).SaveAsync(Arg.Any());
+
+ 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(), Arg.Any())
+ .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());
+
+ 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 device = await _sut.SaveDeviceAsync(user, request);
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
// 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(context.Device);
await _mailService.Received(1).SendNewDeviceLoggedInEmail(
- user.Email, "Android", Arg.Any(), Arg.Any());
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ Assert.True(result);
}
- [Theory]
- [BitAutoData]
- public async void SaveDeviceAsync_ExistingUser_NewDevice_ReturnsDevice_SendEmailFalse(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
- User user)
+ [Theory, BitAutoData]
+ public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_NewUser_DoesNotSendEmail_ReturnsTrue(
+ CustomValidatorRequestContext context,
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
- request = AddValidDeviceToRequest(request);
+ 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(), Arg.Any(), Arg.Any(), Arg.Any());
+ Assert.True(result);
+ }
- user.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
+ [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 device = await _sut.SaveDeviceAsync(user, request);
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
// 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(), Arg.Any());
+ await _deviceService.Received(1).SaveAsync(context.Device);
+ await _mailService.Received(0).SendNewDeviceLoggedInEmail(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ Assert.True(result);
}
[Theory]
- [BitAutoData]
- public async void SaveDeviceAsync_DeviceIsKnown_ShouldReturnDevice(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
- User user,
- Device device)
+ [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
- request = AddValidDeviceToRequest(request);
+ 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);
- 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);
+ request.GrantType = grantType;
// Act
- var resultDevice = await _sut.SaveDeviceAsync(user, request);
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
- Assert.Equal(device, resultDevice);
- await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
- Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ await _deviceService.Received(1).SaveAsync(context.Device);
+ Assert.True(result);
}
- [Theory]
- [BitAutoData]
- public async void SaveDeviceAsync_NewUser_DeviceUnknown_ShouldSaveDevice_NoEmail(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
- User user)
+ [Theory, BitAutoData]
+ public async void ValidateRequestDeviceAsync_IsAuthRequest_SavesDevice_ReturnsTrue(
+ CustomValidatorRequestContext context,
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
- request = AddValidDeviceToRequest(request);
- user.CreationDate = DateTime.UtcNow;
- _deviceRepository.GetByIdentifierAsync(Arg.Any(), Arg.Any()).Returns(null as Device);
+ 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 device = await _sut.SaveDeviceAsync(user, request);
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
// 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(), Arg.Any(), Arg.Any(), Arg.Any());
+ await _deviceService.Received(1).SaveAsync(context.Device);
+ Assert.True(result);
}
- [Theory]
- [BitAutoData]
- public async void KnownDeviceAsync_UserNull_ReturnsFalse(
+ [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
- request = AddValidDeviceToRequest(request);
+ 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.KnownDeviceAsync(null, request);
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
+ await _deviceService.Received(0).SaveAsync(Arg.Any());
+
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]
- public async void KnownDeviceAsync_DeviceNull_ReturnsFalse(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
- User user)
+ [BitAutoData("")]
+ [BitAutoData("123456")]
+ public async void HandleNewDeviceVerificationAsync_NewDeviceOtpInvalid_ReturnsInvalidNewDeviceOtp(
+ string newDeviceOtp,
+ CustomValidatorRequestContext context,
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
- // Device raw data is null which will cause the device to be null
+ 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.KnownDeviceAsync(user, request);
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
+ await _userService.DidNotReceive().SendOTPAsync(Arg.Any());
+ await _deviceService.Received(0).SaveAsync(Arg.Any());
+
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 KnownDeviceAsync_DeviceNotInDatabase_ReturnsFalse(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
- User user)
+ [Theory, BitAutoData]
+ public async void HandleNewDeviceVerificationAsync_UserHasNoDevices_ReturnsSuccess(
+ CustomValidatorRequestContext context,
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
- request = AddValidDeviceToRequest(request);
- _deviceRepository.GetByIdentifierAsync(Arg.Any(), Arg.Any())
- .Returns(null as Device);
+ ArrangeForHandleNewDeviceVerificationTest(context, request);
+ _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
+ _globalSettings.EnableNewDeviceVerification = true;
+ _deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([]);
+
// Act
- var result = await _sut.KnownDeviceAsync(user, request);
+ var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
+ await _userService.Received(0).VerifyOTPAsync(Arg.Any(), Arg.Any());
+ await _userService.Received(0).SendOTPAsync(Arg.Any());
+ 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());
+
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 async void KnownDeviceAsync_UserAndDeviceValid_ReturnsTrue(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
- User user,
- Device device)
+ [Theory, BitAutoData]
+ public void NewDeviceOtpRequest_NewDeviceOtpNull_ReturnsFalse(
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
- request = AddValidDeviceToRequest(request);
- _deviceRepository.GetByIdentifierAsync(Arg.Any(), Arg.Any())
- .Returns(device);
+ // 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 = await _sut.KnownDeviceAsync(user, request);
+ 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;
+ }
+
+ ///
+ /// Configures the request context to facilitate testing the HandleNewDeviceVerificationAsync method.
+ ///
+ /// test context
+ /// test request
+ private static void ArrangeForHandleNewDeviceVerificationTest(
+ CustomValidatorRequestContext context,
+ ValidatedTokenRequest request)
+ {
+ context.KnownDevice = false;
+ request.GrantType = "password";
+ context.TwoFactorRequired = false;
+ context.SsoRequired = false;
}
}
diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs
index f7cfd1d3948a..cbe091a44cb6 100644
--- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs
+++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs
@@ -123,6 +123,11 @@ protected override void SetTwoFactorResult(
Dictionary customResponse)
{ }
+ protected override void SetValidationErrorResult(
+ BaseRequestValidationContextFake context,
+ CustomValidatorRequestContext requestContext)
+ { }
+
protected override Task ValidateContextAsync(
BaseRequestValidationContextFake context,
CustomValidatorRequestContext validatorContext)