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)