1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-07 00:21:32 +01:00

[PM-8220] New Device Verification (#5084)

* feat(BaseRequestValidator): 
Add global setting for new device verification.
Refactor BaseRequestValidator enabling better self-documenting code and better single responsibility principle for validators.
Updated DeviceValidator to handle new device verification, behind a feature flag.
Moved IDeviceValidator interface to separate file.
Updated CustomRequestValidator to act as the conduit by which *Validators communicate authentication context between themselves and the RequestValidators.
Adding new test for DeviceValidator class.
Updated tests for BaseRequestValidator as some functionality was moved to the DeviceValidator class.
This commit is contained in:
Ike 2024-12-12 09:08:11 -08:00 committed by GitHub
parent a76a9cb800
commit 867fa848dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1112 additions and 473 deletions

View File

@ -329,7 +329,7 @@ public class RegisterUserCommand : IRegisterUserCommand
{ {
// We validate open registration on send of initial email and here b/c a user could technically start the // 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 // 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) if (_globalSettings.DisableUserRegistration)
{ {
throw new BadRequestException(_disabledUserRegistrationExceptionMsg); throw new BadRequestException(_disabledUserRegistrationExceptionMsg);

View File

@ -41,6 +41,7 @@ public class GlobalSettings : IGlobalSettings
public virtual string HibpApiKey { get; set; } public virtual string HibpApiKey { get; set; }
public virtual bool DisableUserRegistration { get; set; } public virtual bool DisableUserRegistration { get; set; }
public virtual bool DisableEmailNewDevice { get; set; } public virtual bool DisableEmailNewDevice { get; set; }
public virtual bool EnableNewDeviceVerification { get; set; }
public virtual bool EnableCloudCommunication { get; set; } = false; public virtual bool EnableCloudCommunication { get; set; } = false;
public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days
public virtual string EventGridKey { get; set; } public virtual string EventGridKey { get; set; }

View File

@ -14,6 +14,7 @@ public interface IGlobalSettings
string LicenseCertificatePassword { get; set; } string LicenseCertificatePassword { get; set; }
int OrganizationInviteExpirationHours { get; set; } int OrganizationInviteExpirationHours { get; set; }
bool DisableUserRegistration { get; set; } bool DisableUserRegistration { get; set; }
bool EnableNewDeviceVerification { get; set; }
IInstallationSettings Installation { get; set; } IInstallationSettings Installation { get; set; }
IFileStorageSettings Attachment { get; set; } IFileStorageSettings Attachment { get; set; }
IConnectionStringSettings Storage { get; set; } IConnectionStringSettings Storage { get; set; }

View File

@ -1,11 +1,43 @@
using Bit.Core.Auth.Models.Business; using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities; using Bit.Core.Entities;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer;
public class CustomValidatorRequestContext public class CustomValidatorRequestContext
{ {
public User User { get; set; } public User User { get; set; }
/// <summary>
/// This is the device that the user is using to authenticate. It can be either known or unknown.
/// We set it here since the ResourceOwnerPasswordValidator needs the device to know if CAPTCHA is required.
/// The option to set it here saves a trip to the database.
/// </summary>
public Device Device { get; set; }
/// <summary>
/// Communicates whether or not the device in the request is known to the user.
/// KnownDevice is set in the child classes of the BaseRequestValidator using the DeviceValidator.KnownDeviceAsync method.
/// Except in the CustomTokenRequestValidator, where it is hardcoded to true.
/// </summary>
public bool KnownDevice { get; set; } public bool KnownDevice { get; set; }
/// <summary>
/// This communicates whether or not two factor is required for the user to authenticate.
/// </summary>
public bool TwoFactorRequired { get; set; } = false;
/// <summary>
/// This communicates whether or not SSO is required for the user to authenticate.
/// </summary>
public bool SsoRequired { get; set; } = false;
/// <summary>
/// We use the parent class for both GrantValidationResult and TokenRequestValidationResult here for
/// flexibility when building an error response.
/// This will be null if the authentication request is successful.
/// </summary>
public ValidationResult ValidationErrorResult { get; set; }
/// <summary>
/// This dictionary should contain relevant information for the clients to act on.
/// This will contain the information used to guide a user to successful authentication, such as TwoFactorProviders.
/// This will be null if the authentication request is successful.
/// </summary>
public Dictionary<string, object> CustomResponse { get; set; }
public CaptchaResponse CaptchaResponse { get; set; } public CaptchaResponse CaptchaResponse { get; set; }
} }

View File

@ -0,0 +1,10 @@
namespace Bit.Identity.IdentityServer.Enums;
public enum DeviceValidationResultType : byte
{
Success = 0,
InvalidUser = 1,
InvalidNewDeviceOtp = 2,
NewDeviceVerificationRequired = 3,
NoDeviceInformationProvided = 4
}

View File

@ -77,37 +77,51 @@ public abstract class BaseRequestValidator<T> where T : class
protected async Task ValidateAsync(T context, ValidatedTokenRequest request, protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
CustomValidatorRequestContext validatorContext) CustomValidatorRequestContext validatorContext)
{ {
// 1. we need to check if the user is a bot and if their master password hash is correct
var isBot = validatorContext.CaptchaResponse?.IsBot ?? false; var isBot = validatorContext.CaptchaResponse?.IsBot ?? false;
var valid = await ValidateContextAsync(context, validatorContext);
var user = validatorContext.User;
if (!valid || isBot)
{
if (isBot) if (isBot)
{ {
_logger.LogInformation(Constants.BypassFiltersEventId, _logger.LogInformation(Constants.BypassFiltersEventId,
"Login attempt for {0} detected as a captcha bot with score {1}.", "Login attempt for {UserName} detected as a captcha bot with score {CaptchaScore}.",
request.UserName, validatorContext.CaptchaResponse.Score); request.UserName, validatorContext.CaptchaResponse.Score);
} }
var valid = await ValidateContextAsync(context, validatorContext);
var user = validatorContext.User;
if (!valid) if (!valid)
{ {
await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice); await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice);
} }
if (!valid || isBot)
{
await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user); await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
return; return;
} }
var (isTwoFactorRequired, twoFactorOrganization) = await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request); // 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<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
return;
}
// 3. Check if 2FA is required
(validatorContext.TwoFactorRequired, var twoFactorOrganization) = await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
// This flag is used to determine if the user wants a rememberMe token sent when authentication is successful
var returnRememberMeToken = false;
if (validatorContext.TwoFactorRequired)
{
var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString(); var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString();
var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString(); var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString();
var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1";
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
!string.IsNullOrWhiteSpace(twoFactorProvider); !string.IsNullOrWhiteSpace(twoFactorProvider);
// response for 2FA required and not provided state
if (isTwoFactorRequired)
{
// 2FA required and not provided response
if (!validTwoFactorRequest || if (!validTwoFactorRequest ||
!Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType)) !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
{ {
@ -125,18 +139,14 @@ public abstract class BaseRequestValidator<T> where T : class
return; return;
} }
var verified = await _twoFactorAuthenticationValidator var twoFactorTokenValid = await _twoFactorAuthenticationValidator
.VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken); .VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
// 2FA required but request not valid or remember token expired response // response for 2FA required but request is not valid or remember token expired state
if (!verified || isBot) if (!twoFactorTokenValid)
{ {
if (twoFactorProviderType != TwoFactorProviderType.Remember) // The remember me token has expired
{ 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)
{ {
var resultDict = await _twoFactorAuthenticationValidator var resultDict = await _twoFactorAuthenticationValidator
.BuildTwoFactorResultAsync(user, twoFactorOrganization); .BuildTwoFactorResultAsync(user, twoFactorOrganization);
@ -145,16 +155,34 @@ public abstract class BaseRequestValidator<T> where T : class
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user)); resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
SetTwoFactorResult(context, resultDict); SetTwoFactorResult(context, resultDict);
} }
return;
}
}
else else
{ {
validTwoFactorRequest = false; await UpdateFailedAuthDetailsAsync(user, true, !validatorContext.KnownDevice);
twoFactorRemember = false; await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
}
return;
} }
// Force legacy users to the web for migration // 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;
}
}
// 4. Check if the user is logging in from a new device
var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
if (!deviceValid)
{
SetValidationErrorResult(context, validatorContext);
await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
return;
}
// 5. Force legacy users to the web for migration
if (FeatureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers)) if (FeatureService.IsEnabled(FeatureFlagKeys.BlockLegacyUsers))
{ {
if (UserService.IsLegacyUser(user) && request.ClientId != "web") if (UserService.IsLegacyUser(user) && request.ClientId != "web")
@ -164,24 +192,7 @@ public abstract class BaseRequestValidator<T> where T : class
} }
} }
if (await IsValidAuthTypeAsync(user, request.GrantType)) await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
{
var device = await _deviceValidator.SaveDeviceAsync(user, request);
if (device == null)
{
await BuildErrorResultAsync("No device information provided.", false, context, user);
return;
}
await BuildSuccessResultAsync(user, context, device, validTwoFactorRequest && twoFactorRemember);
}
else
{
SetSsoResult(context,
new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
}
} }
protected async Task FailAuthForLegacyUserAsync(User user, T context) protected async Task FailAuthForLegacyUserAsync(User user, T context)
@ -235,6 +246,17 @@ public abstract class BaseRequestValidator<T> where T : class
await SetSuccessResult(context, user, claims, customResponse); await SetSuccessResult(context, user, claims, customResponse);
} }
/// <summary>
/// This does two things, it sets the error result for the current ValidatorContext _and_ it logs error.
/// These two things should be seperated to maintain single concerns.
/// </summary>
/// <param name="message">Error message for the error result</param>
/// <param name="twoFactorRequest">bool that controls how the error is logged</param>
/// <param name="context">used to set the error result in the current validator</param>
/// <param name="user">used to associate the failed login with a user</param>
/// <returns>void</returns>
[Obsolete("Consider using SetValidationErrorResult to set the validation result, and LogFailedLoginEvent " +
"to log the failure.")]
protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user) protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user)
{ {
if (user != null) if (user != null)
@ -255,43 +277,82 @@ public abstract class BaseRequestValidator<T> where T : class
new Dictionary<string, object> { { "ErrorModel", new ErrorResponseModel(message) } }); new Dictionary<string, object> { { "ErrorModel", new ErrorResponseModel(message) } });
} }
protected async Task LogFailedLoginEvent(User user, EventType eventType)
{
if (user != null)
{
await _eventService.LogUserEventAsync(user.Id, eventType);
}
if (_globalSettings.SelfHosted)
{
string formattedMessage;
switch (eventType)
{
case EventType.User_FailedLogIn:
formattedMessage = string.Format("Failed login attempt. {0}", $" {CurrentContext.IpAddress}");
break;
case EventType.User_FailedLogIn2fa:
formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}", $" {CurrentContext.IpAddress}");
break;
default:
formattedMessage = "Failed login attempt.";
break;
}
_logger.LogWarning(Constants.BypassFiltersEventId, formattedMessage);
}
await Task.Delay(2000); // Delay for brute force.
}
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetTwoFactorResult(T context, Dictionary<string, object> customResponse); protected abstract void SetTwoFactorResult(T context, Dictionary<string, object> customResponse);
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetSsoResult(T context, Dictionary<string, object> customResponse); protected abstract void SetSsoResult(T context, Dictionary<string, object> customResponse);
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
/// <summary>
/// This consumes the ValidationErrorResult property in the CustomValidatorRequestContext and sets
/// it appropriately in the response object for the token and grant validators.
/// </summary>
/// <param name="context">The current grant or token context</param>
/// <param name="requestContext">The modified request context containing material used to build the response object</param>
protected abstract void SetValidationErrorResult(T context, CustomValidatorRequestContext requestContext);
protected abstract Task SetSuccessResult(T context, User user, List<Claim> claims, protected abstract Task SetSuccessResult(T context, User user, List<Claim> claims,
Dictionary<string, object> customResponse); Dictionary<string, object> customResponse);
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
protected abstract ClaimsPrincipal GetSubject(T context); protected abstract ClaimsPrincipal GetSubject(T context);
/// <summary> /// <summary>
/// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are /// 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. /// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement.
/// If the GrantType is authorization_code or client_credentials we know the user is trying to login
/// using the SSO flow so they are allowed to continue.
/// </summary> /// </summary>
/// <param name="user">user trying to login</param> /// <param name="user">user trying to login</param>
/// <param name="grantType">magic string identifying the grant type requested</param> /// <param name="grantType">magic string identifying the grant type requested</param>
/// <returns></returns> /// <returns>true if sso required; false if not required or already in process</returns>
private async Task<bool> IsValidAuthTypeAsync(User user, string grantType) private async Task<bool> RequireSsoLoginAsync(User user, string grantType)
{ {
if (grantType == "authorization_code" || grantType == "client_credentials") if (grantType == "authorization_code" || grantType == "client_credentials")
{ {
// Already using SSO to authorize, finish successfully // Already using SSO to authenticate, or logging-in via api key to skip SSO requirement
// Or login via api key, skip SSO requirement // allow to authenticate successfully
return true;
}
// Check if user belongs to any organization with an active SSO policy
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser)
{
return false; return false;
} }
// Default - continue validation process // Check if user belongs to any organization with an active SSO policy
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser)
{
return true; return true;
} }
// Default - SSO is not required
return false;
}
private async Task ResetFailedAuthDetailsAsync(User user) private async Task ResetFailedAuthDetailsAsync(User user)
{ {
// Early escape if db hit not necessary // Early escape if db hit not necessary
@ -350,7 +411,7 @@ public abstract class BaseRequestValidator<T> where T : class
var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
.ToList(); .ToList();
if (!orgs.Any()) if (orgs.Count == 0)
{ {
return null; return null;
} }

View File

@ -89,8 +89,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
} }
return; return;
} }
await ValidateAsync(context, context.Result.ValidatedRequest, await ValidateAsync(context, context.Result.ValidatedRequest, new CustomValidatorRequestContext { });
new CustomValidatorRequestContext { KnownDevice = true });
} }
protected async override Task<bool> ValidateContextAsync(CustomTokenRequestValidationContext context, protected async override Task<bool> ValidateContextAsync(CustomTokenRequestValidationContext context,
@ -162,6 +161,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
return context.Result.ValidatedRequest.Subject; return context.Result.ValidatedRequest.Subject;
} }
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context, protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
@ -172,16 +172,18 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
context.Result.CustomResponse = customResponse; context.Result.CustomResponse = customResponse;
} }
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetSsoResult(CustomTokenRequestValidationContext context, protected override void SetSsoResult(CustomTokenRequestValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
Debug.Assert(context.Result is not null); Debug.Assert(context.Result is not null);
context.Result.Error = "invalid_grant"; context.Result.Error = "invalid_grant";
context.Result.ErrorDescription = "Single Sign on required."; context.Result.ErrorDescription = "Sso authentication required.";
context.Result.IsError = true; context.Result.IsError = true;
context.Result.CustomResponse = customResponse; context.Result.CustomResponse = customResponse;
} }
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetErrorResult(CustomTokenRequestValidationContext context, protected override void SetErrorResult(CustomTokenRequestValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
@ -190,4 +192,14 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
context.Result.IsError = true; context.Result.IsError = true;
context.Result.CustomResponse = customResponse; 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;
}
} }

View File

@ -1,95 +1,162 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Reflection; using System.Reflection;
using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Identity.IdentityServer.Enums;
using Duende.IdentityServer.Validation; using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators; namespace Bit.Identity.IdentityServer.RequestValidators;
public interface IDeviceValidator
{
/// <summary>
/// Save a device to the database. If the device is already known, it will be returned.
/// </summary>
/// <param name="user">The user is assumed NOT null, still going to check though</param>
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request);
/// <summary>
/// Check if a device is known to the user.
/// </summary>
/// <param name="user">current user trying to authenticate</param>
/// <param name="request">contains raw information that is parsed about the device</param>
/// <returns>true if the device is known, false if it is not</returns>
Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request);
}
public class DeviceValidator( public class DeviceValidator(
IDeviceService deviceService, IDeviceService deviceService,
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IMailService mailService, IMailService mailService,
ICurrentContext currentContext) : IDeviceValidator ICurrentContext currentContext,
IUserService userService,
IFeatureService featureService) : IDeviceValidator
{ {
private readonly IDeviceService _deviceService = deviceService; private readonly IDeviceService _deviceService = deviceService;
private readonly IDeviceRepository _deviceRepository = deviceRepository; private readonly IDeviceRepository _deviceRepository = deviceRepository;
private readonly GlobalSettings _globalSettings = globalSettings; private readonly GlobalSettings _globalSettings = globalSettings;
private readonly IMailService _mailService = mailService; private readonly IMailService _mailService = mailService;
private readonly ICurrentContext _currentContext = currentContext; private readonly ICurrentContext _currentContext = currentContext;
private readonly IUserService _userService = userService;
private readonly IFeatureService _featureService = featureService;
/// <summary> public async Task<bool> ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context)
/// Save a device to the database. If the device is already known, it will be returned.
/// </summary>
/// <param name="user">The user is assumed NOT null, still going to check though</param>
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
public async Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request)
{ {
var device = GetDeviceFromRequest(request); // Parse device from request and return early if no device information is provided
if (device != null && user != null) var requestDevice = context.Device ?? GetDeviceFromRequest(request);
// If context.Device and request device information are null then return error
// backwards compatibility -- check if user is null
// PM-13340: Null user check happens in the HandleNewDeviceVerificationAsync method and can be removed from here
if (requestDevice == null || context.User == null)
{ {
var existingDevice = await GetKnownDeviceAsync(user, device); (context.ValidationErrorResult, context.CustomResponse) =
if (existingDevice == null) BuildDeviceErrorResult(DeviceValidationResultType.NoDeviceInformationProvided);
{ return false;
device.UserId = user.Id; }
await _deviceService.SaveAsync(device);
// This makes sure the user isn't sent a "new device" email on their first login // if not a new device request then check if the device is known
if (!NewDeviceOtpRequest(request))
{
var knownDevice = await GetKnownDeviceAsync(context.User, requestDevice);
// if the device is know then we return the device fetched from the database
// returning the database device is important for TDE
if (knownDevice != null)
{
context.KnownDevice = true;
context.Device = knownDevice;
return true;
}
}
// We have established that the device is unknown at this point; begin new device verification
// PM-13340: remove feature flag
if (_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) &&
request.GrantType == "password" &&
request.Raw["AuthRequest"] == null &&
!context.TwoFactorRequired &&
!context.SsoRequired &&
_globalSettings.EnableNewDeviceVerification)
{
// We only want to return early if the device is invalid or there is an error
var validationResult = await HandleNewDeviceVerificationAsync(context.User, request);
if (validationResult != DeviceValidationResultType.Success)
{
(context.ValidationErrorResult, context.CustomResponse) =
BuildDeviceErrorResult(validationResult);
if (validationResult == DeviceValidationResultType.NewDeviceVerificationRequired)
{
await _userService.SendOTPAsync(context.User);
}
return false;
}
}
// At this point we have established either new device verification is not required or the NewDeviceOtp is valid
requestDevice.UserId = context.User.Id;
await _deviceService.SaveAsync(requestDevice);
context.Device = requestDevice;
// backwards compatibility -- If NewDeviceVerification not enabled send the new login emails
// PM-13340: removal Task; remove entire if block emails should no longer be sent
if (!_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification))
{
// This ensures the user doesn't receive a "new device" email on the first login
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (now - user.CreationDate > TimeSpan.FromMinutes(10)) if (now - context.User.CreationDate > TimeSpan.FromMinutes(10))
{ {
var deviceType = device.Type.GetType().GetMember(device.Type.ToString()) var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString())
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName(); .FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
if (!_globalSettings.DisableEmailNewDevice) if (!_globalSettings.DisableEmailNewDevice)
{ {
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now, await _mailService.SendNewDeviceLoggedInEmail(context.User.Email, deviceType, now,
_currentContext.IpAddress); _currentContext.IpAddress);
} }
} }
return device;
} }
return existingDevice; return true;
}
return null;
} }
public async Task<bool> KnownDeviceAsync(User user, ValidatedTokenRequest request) => /// <summary>
(await GetKnownDeviceAsync(user, GetDeviceFromRequest(request))) != default; /// Checks the if the requesting deice requires new device verification otherwise saves the device to the database
/// </summary>
/// <param name="user">user attempting to authenticate</param>
/// <param name="ValidatedRequest">The Request is used to check for the NewDeviceOtp and for the raw device data</param>
/// <returns>returns deviceValtaionResultType</returns>
private async Task<DeviceValidationResultType> HandleNewDeviceVerificationAsync(User user, ValidatedRequest request)
{
// currently unreachable due to backward compatibility
// PM-13340: will address this
if (user == null)
{
return DeviceValidationResultType.InvalidUser;
}
private async Task<Device> GetKnownDeviceAsync(User user, Device device) // parse request for NewDeviceOtp to validate
var newDeviceOtp = request.Raw["NewDeviceOtp"]?.ToString();
// we only check null here since an empty OTP will be considered an incorrect OTP
if (newDeviceOtp != null)
{
// verify the NewDeviceOtp
var otpValid = await _userService.VerifyOTPAsync(user, newDeviceOtp);
if (otpValid)
{
return DeviceValidationResultType.Success;
}
return DeviceValidationResultType.InvalidNewDeviceOtp;
}
// if a user has no devices they are assumed to be newly registered user which does not require new device verification
var devices = await _deviceRepository.GetManyByUserIdAsync(user.Id);
if (devices.Count == 0)
{
return DeviceValidationResultType.Success;
}
// if we get to here then we need to send a new device verification email
return DeviceValidationResultType.NewDeviceVerificationRequired;
}
public async Task<Device> GetKnownDeviceAsync(User user, Device device)
{ {
if (user == null || device == null) if (user == null || device == null)
{ {
return default; return null;
} }
return await _deviceRepository.GetByIdentifierAsync(device.Identifier, user.Id); 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 deviceIdentifier = request.Raw["DeviceIdentifier"]?.ToString();
var requestDeviceType = request.Raw["DeviceType"]?.ToString(); var requestDeviceType = request.Raw["DeviceType"]?.ToString();
@ -112,4 +179,49 @@ public class DeviceValidator(
PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken PushToken = string.IsNullOrWhiteSpace(devicePushToken) ? null : devicePushToken
}; };
} }
/// <summary>
/// Checks request for the NewDeviceOtp field to determine if a new device verification is required.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
public static bool NewDeviceOtpRequest(ValidatedRequest request)
{
return !string.IsNullOrEmpty(request.Raw["NewDeviceOtp"]?.ToString());
}
/// <summary>
/// This builds builds the error result for the various grant and token validators. The Success type is not used here.
/// </summary>
/// <param name="errorType">DeviceValidationResultType that is an error, success type is not used.</param>
/// <returns>validation result used by grant and token validators, and the custom response for either Grant or Token response objects.</returns>
private static (Duende.IdentityServer.Validation.ValidationResult, Dictionary<string, object>) BuildDeviceErrorResult(DeviceValidationResultType errorType)
{
var result = new Duende.IdentityServer.Validation.ValidationResult
{
IsError = true,
Error = "device_error",
};
var customResponse = new Dictionary<string, object>();
switch (errorType)
{
case DeviceValidationResultType.InvalidUser:
result.ErrorDescription = "Invalid user";
customResponse.Add("ErrorModel", new ErrorResponseModel("invalid user"));
break;
case DeviceValidationResultType.InvalidNewDeviceOtp:
result.ErrorDescription = "Invalid New Device OTP";
customResponse.Add("ErrorModel", new ErrorResponseModel("invalid new device otp"));
break;
case DeviceValidationResultType.NewDeviceVerificationRequired:
result.ErrorDescription = "New device verification required";
customResponse.Add("ErrorModel", new ErrorResponseModel("new device verification required"));
break;
case DeviceValidationResultType.NoDeviceInformationProvided:
result.ErrorDescription = "No device information provided";
customResponse.Add("ErrorModel", new ErrorResponseModel("no device information provided"));
break;
}
return (result, customResponse);
}
} }

View File

@ -0,0 +1,24 @@
using Bit.Core.Entities;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators;
public interface IDeviceValidator
{
/// <summary>
/// Fetches device from the database using the Device Identifier and the User Id to know if the user
/// has ever tried to authenticate with this specific instance of Bitwarden.
/// </summary>
/// <param name="user">user attempting to authenticate</param>
/// <param name="device">current instance of Bitwarden the user is interacting with</param>
/// <returns>null or Device</returns>
Task<Device> GetKnownDeviceAsync(User user, Device device);
/// <summary>
/// Validate the requesting device. Modifies the ValidatorRequestContext with error result if any.
/// </summary>
/// <param name="request">The Request is used to check for the NewDeviceOtp and for the raw device data</param>
/// <param name="context">Contains two factor and sso context that are important for decisions on new device verification</param>
/// <returns>returns true if device is valid and no other action required; if false modifies the context with an error result to be returned;</returns>
Task<bool> ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context);
}

View File

@ -75,11 +75,16 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
} }
var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant()); 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 var validatorContext = new CustomValidatorRequestContext
{ {
User = user, User = user,
KnownDevice = await _deviceValidator.KnownDeviceAsync(user, context.Request), KnownDevice = knownDevice != null,
Device = knownDevice ?? requestDevice,
}; };
string bypassToken = null; string bypassToken = null;
if (!validatorContext.KnownDevice && if (!validatorContext.KnownDevice &&
_captchaValidationService.RequireCaptchaValidation(_currentContext, user)) _captchaValidationService.RequireCaptchaValidation(_currentContext, user))
@ -156,6 +161,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
return Task.CompletedTask; return Task.CompletedTask;
} }
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetTwoFactorResult(ResourceOwnerPasswordValidationContext context, protected override void SetTwoFactorResult(ResourceOwnerPasswordValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
@ -163,6 +169,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
customResponse); customResponse);
} }
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetSsoResult(ResourceOwnerPasswordValidationContext context, protected override void SetSsoResult(ResourceOwnerPasswordValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
@ -170,12 +177,25 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
customResponse); customResponse);
} }
[Obsolete("Consider using SetGrantValidationErrorResult instead.")]
protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context, protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: 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) protected override ClaimsPrincipal GetSubject(ResourceOwnerPasswordValidationContext context)
{ {
return context.Result.Subject; return context.Result.Subject;
@ -183,28 +203,26 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
private bool AuthEmailHeaderIsValid(ResourceOwnerPasswordValidationContext context) private bool AuthEmailHeaderIsValid(ResourceOwnerPasswordValidationContext context)
{ {
if (!_currentContext.HttpContext.Request.Headers.ContainsKey("Auth-Email")) if (_currentContext.HttpContext.Request.Headers.TryGetValue("Auth-Email", out var authEmailHeader))
{
return false;
}
else
{ {
try try
{ {
var authEmailHeader = _currentContext.HttpContext.Request.Headers["Auth-Email"];
var authEmailDecoded = CoreHelpers.Base64UrlDecodeString(authEmailHeader); var authEmailDecoded = CoreHelpers.Base64UrlDecodeString(authEmailHeader);
if (authEmailDecoded != context.UserName) if (authEmailDecoded != context.UserName)
{ {
return false; 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 // Invalid B64 encoding
return false; return false;
} }
} }
else
{
return false;
}
return true; return true;
} }

View File

@ -91,15 +91,9 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
} }
var (user, credential) = await _assertWebAuthnLoginCredentialCommand.AssertWebAuthnLoginCredential(token.Options, deviceResponse); 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); UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential);
await ValidateAsync(context, context.Request, validatorContext); await ValidateAsync(context, context.Request, new CustomValidatorRequestContext { User = user });
} }
protected override Task<bool> ValidateContextAsync(ExtensionGrantValidationContext context, protected override Task<bool> ValidateContextAsync(ExtensionGrantValidationContext context,
@ -128,6 +122,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
return context.Result.Subject; return context.Result.Subject;
} }
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected override void SetTwoFactorResult(ExtensionGrantValidationContext context, protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
@ -135,6 +130,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
customResponse); customResponse);
} }
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected override void SetSsoResult(ExtensionGrantValidationContext context, protected override void SetSsoResult(ExtensionGrantValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
@ -142,9 +138,21 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
customResponse); customResponse);
} }
protected override void SetErrorResult(ExtensionGrantValidationContext context, [Obsolete("Consider using SetValidationErrorResult instead.")]
Dictionary<string, object> customResponse) protected override void SetErrorResult(ExtensionGrantValidationContext context, Dictionary<string, object> customResponse)
{ {
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: 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
};
}
} }

View File

@ -5,14 +5,11 @@ using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Identity.IdentityServer.RequestValidators;
using Bit.Identity.Models.Request.Accounts; using Bit.Identity.Models.Request.Accounts;
using Bit.IntegrationTestCommon.Factories; using Bit.IntegrationTestCommon.Factories;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers; using Bit.Test.Common.Helpers;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit; using Xunit;
namespace Bit.Identity.IntegrationTest.RequestValidation; namespace Bit.Identity.IntegrationTest.RequestValidation;
@ -217,48 +214,6 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
Assert.Equal("Username or password is incorrect. Try again.", errorMessage); Assert.Equal("Username or password is incorrect. Try again.", errorMessage);
} }
[Fact]
public async Task ValidateAsync_DeviceSaveAsync_ReturnsNullDevice_ErrorResult()
{
// Arrange
var factory = new IdentityApplicationFactory();
// Stub DeviceValidator
factory.SubstituteService<IDeviceValidator>(sub =>
{
sub.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(null as Device);
});
// Add User
await factory.RegisterAsync(new RegisterRequestModel
{
Email = DefaultUsername,
MasterPasswordHash = DefaultPassword
});
var userManager = factory.GetService<UserManager<User>>();
await factory.RegisterAsync(new RegisterRequestModel
{
Email = DefaultUsername,
MasterPasswordHash = DefaultPassword
});
var user = await userManager.FindByEmailAsync(DefaultUsername);
Assert.NotNull(user);
// Act
var context = await factory.Server.PostAsync("/connect/token",
GetFormUrlEncodedContent(),
context => context.SetAuthEmail(DefaultUsername));
// Assert
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = body.RootElement;
var errorModel = AssertHelper.AssertJsonProperty(root, "ErrorModel", JsonValueKind.Object);
var errorMessage = AssertHelper.AssertJsonProperty(errorModel, "Message", JsonValueKind.String).GetString();
Assert.Equal("No device information provided.", errorMessage);
}
private async Task EnsureUserCreatedAsync(IdentityApplicationFactory factory = null) private async Task EnsureUserCreatedAsync(IdentityApplicationFactory factory = null)
{ {
factory ??= _factory; factory ??= _factory;
@ -290,6 +245,18 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
}); });
} }
private FormUrlEncodedContent GetDefaultFormUrlEncodedContentWithoutDevice()
{
return new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "grant_type", "password" },
{ "username", DefaultUsername },
{ "password", DefaultPassword },
});
}
private static string DeviceTypeAsString(DeviceType deviceType) private static string DeviceTypeAsString(DeviceType deviceType)
{ {
return ((int)deviceType).ToString(); return ((int)deviceType).ToString();

View File

@ -22,7 +22,6 @@ using NSubstitute;
using Xunit; using Xunit;
using AuthFixtures = Bit.Identity.Test.AutoFixture; using AuthFixtures = Bit.Identity.Test.AutoFixture;
namespace Bit.Identity.Test.IdentityServer; namespace Bit.Identity.Test.IdentityServer;
public class BaseRequestValidatorTests public class BaseRequestValidatorTests
@ -82,9 +81,9 @@ public class BaseRequestValidatorTests
} }
/* Logic path /* Logic path
ValidateAsync -> _Logger.LogInformation * ValidateAsync -> _Logger.LogInformation
|-> BuildErrorResultAsync -> _eventService.LogUserEventAsync * |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|-> SetErrorResult * |-> SetErrorResult
*/ */
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task ValidateAsync_IsBot_UserNotNull_ShouldBuildErrorResult_ShouldLogFailedLoginEvent( public async Task ValidateAsync_IsBot_UserNotNull_ShouldBuildErrorResult_ShouldLogFailedLoginEvent(
@ -112,10 +111,10 @@ public class BaseRequestValidatorTests
} }
/* Logic path /* Logic path
ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync * ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
|-> BuildErrorResultAsync -> _eventService.LogUserEventAsync * |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
(self hosted) |-> _logger.LogWarning() * (self hosted) |-> _logger.LogWarning()
|-> SetErrorResult * |-> SetErrorResult
*/ */
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning( public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning(
@ -140,9 +139,9 @@ public class BaseRequestValidatorTests
} }
/* Logic path /* Logic path
ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync * ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
|-> BuildErrorResultAsync -> _eventService.LogUserEventAsync * |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
|-> SetErrorResult * |-> SetErrorResult
*/ */
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task ValidateAsync_ContextNotValid_MaxAttemptLogin_ShouldSendEmail( public async Task ValidateAsync_ContextNotValid_MaxAttemptLogin_ShouldSendEmail(
@ -177,134 +176,97 @@ public class BaseRequestValidatorTests
Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message); Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
} }
/* Logic path
ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildErrorResult
*/
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task ValidateAsync_AuthCodeGrantType_DeviceNull_ShouldError( public async Task ValidateAsync_DeviceNotValidated_ShouldLogError(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext, CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult) GrantValidationResult grantResult)
{ {
// Arrange // Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult); var context = CreateContext(tokenRequest, requestContext, grantResult);
_twoFactorAuthenticationValidator // 1 -> to pass
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, default)));
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false; context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true; _sut.isValid = true;
context.ValidatedTokenRequest.GrantType = "authorization_code"; // 2 -> will result to false with no extra configuration
// 3 -> set two factor to be false
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// 4 -> set up device validator to fail
requestContext.KnownDevice = false;
tokenRequest.GrantType = "password";
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(false));
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>())
.Returns(false);
// Act // Act
await _sut.ValidateAsync(context); await _sut.ValidateAsync(context);
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
// Assert // Assert
Assert.True(context.GrantResult.IsError); 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] [Theory, BitAutoData]
public async Task ValidateAsync_ClientCredentialsGrantType_ShouldSucceed( public async Task ValidateAsync_DeviceValidated_ShouldSucceed(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult,
Device device)
{
// Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult);
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
context.CustomValidatorRequestContext.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(1);
_globalSettings.DisableEmailNewDevice = false;
context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device
_deviceValidator.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(device);
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.False(context.GrantResult.IsError);
}
/* Logic path
ValidateAsync -> IsValidAuthTypeAsync -> SaveDeviceAsync -> BuildSuccessResultAsync
*/
[Theory, BitAutoData]
public async Task ValidateAsync_ClientCredentialsGrantType_ExistingDevice_ShouldSucceed(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult,
Device device)
{
// Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult);
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
context.CustomValidatorRequestContext.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(1);
_globalSettings.DisableEmailNewDevice = false;
context.ValidatedTokenRequest.GrantType = "client_credentials"; // This || AuthCode will allow process to continue to get device
_deviceValidator.SaveDeviceAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(device);
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// Act
await _sut.ValidateAsync(context);
// Assert
await _eventService.LogUserEventAsync(
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
Assert.False(context.GrantResult.IsError);
}
/* Logic path
ValidateAsync -> IsLegacyUser -> BuildErrorResultAsync
*/
[Theory, BitAutoData]
public async Task ValidateAsync_InvalidAuthType_ShouldSetSsoResult(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext, CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult) GrantValidationResult grantResult)
{ {
// Arrange // Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult); var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
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; context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true; _sut.isValid = true;
context.ValidatedTokenRequest.GrantType = ""; // 2 -> will result to false with no extra configuration
// 3 -> set two factor to be false
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// 4 -> set up device validator to pass
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(true));
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>())
.Returns(false);
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.False(context.GrantResult.IsError);
}
// Test grantTypes that require SSO when a user is in an organization that requires it
[Theory]
[BitAutoData("password")]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredTrue_ShouldSetSsoResult(
string grantType,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult);
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
_policyService.AnyPoliciesApplicableToUserAsync( _policyService.AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(true)); .Returns(Task.FromResult(true));
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// Act // Act
await _sut.ValidateAsync(context); await _sut.ValidateAsync(context);
@ -314,6 +276,85 @@ public class BaseRequestValidatorTests
Assert.Equal("SSO authentication is required.", errorResponse.Message); Assert.Equal("SSO authentication is required.", errorResponse.Message);
} }
// Test grantTypes where SSO would be required but the user is not in an
// organization that requires it
[Theory]
[BitAutoData("password")]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredFalse_ShouldSucceed(
string grantType,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult);
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
_policyService.AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(false));
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
context.ValidatedTokenRequest.ClientId = "web";
// Act
await _sut.ValidateAsync(context);
// Assert
await _eventService.Received(1).LogUserEventAsync(
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
Assert.False(context.GrantResult.IsError);
}
// Test the grantTypes where SSO is in progress or not relevant
[Theory]
[BitAutoData("authorization_code")]
[BitAutoData("client_credentials")]
public async Task ValidateAsync_GrantTypes_SsoRequiredFalse_ShouldSucceed(
string grantType,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult);
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
context.ValidatedTokenRequest.ClientId = "web";
// Act
await _sut.ValidateAsync(context);
// Assert
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
await _eventService.Received(1).LogUserEventAsync(
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
Assert.False(context.GrantResult.IsError);
}
/* Logic Path
* ValidateAsync -> UserService.IsLegacyUser -> FailAuthForLegacyUserAsync
*/
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync( public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
@ -332,6 +373,8 @@ public class BaseRequestValidatorTests
_twoFactorAuthenticationValidator _twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>()) .RequiresTwoFactorAsync(Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>())
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null))); .Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
// Act // Act
await _sut.ValidateAsync(context); await _sut.ValidateAsync(context);
@ -339,8 +382,9 @@ public class BaseRequestValidatorTests
// Assert // Assert
Assert.True(context.GrantResult.IsError); Assert.True(context.GrantResult.IsError);
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
Assert.Equal($"Encryption key migration is required. Please log in to the web vault at {_globalSettings.BaseServiceUri.VaultWithHash}" var expectedMessage = $"Encryption key migration is required. Please log in to the web " +
, errorResponse.Message); $"vault at {_globalSettings.BaseServiceUri.VaultWithHash}";
Assert.Equal(expectedMessage, errorResponse.Message);
} }
private BaseRequestValidationContextFake CreateContext( private BaseRequestValidationContextFake CreateContext(
@ -367,4 +411,12 @@ public class BaseRequestValidatorTests
Substitute.For<IServiceProvider>(), Substitute.For<IServiceProvider>(),
Substitute.For<ILogger<UserManager<User>>>()); Substitute.For<ILogger<UserManager<User>>>());
} }
private void AddValidDeviceToRequest(ValidatedTokenRequest request)
{
request.Raw["DeviceIdentifier"] = "DeviceIdentifier";
request.Raw["DeviceType"] = "Android"; // must be valid device type
request.Raw["DeviceName"] = "DeviceName";
request.Raw["DevicePushToken"] = "DevicePushToken";
}
} }

View File

@ -1,9 +1,12 @@
using Bit.Core.Context; using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Identity.IdentityServer;
using Bit.Identity.IdentityServer.RequestValidators; using Bit.Identity.IdentityServer.RequestValidators;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityServer.Validation; using Duende.IdentityServer.Validation;
@ -20,6 +23,8 @@ public class DeviceValidatorTests
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly DeviceValidator _sut; private readonly DeviceValidator _sut;
public DeviceValidatorTests() public DeviceValidatorTests()
@ -29,219 +34,550 @@ public class DeviceValidatorTests
_globalSettings = new GlobalSettings(); _globalSettings = new GlobalSettings();
_mailService = Substitute.For<IMailService>(); _mailService = Substitute.For<IMailService>();
_currentContext = Substitute.For<ICurrentContext>(); _currentContext = Substitute.For<ICurrentContext>();
_userService = Substitute.For<IUserService>();
_featureService = Substitute.For<IFeatureService>();
_sut = new DeviceValidator( _sut = new DeviceValidator(
_deviceService, _deviceService,
_deviceRepository, _deviceRepository,
_globalSettings, _globalSettings,
_mailService, _mailService,
_currentContext); _currentContext,
_userService,
_featureService);
} }
[Theory] [Theory, BitAutoData]
[BitAutoData] public async void GetKnownDeviceAsync_UserNull_ReturnsFalse(
public async void SaveDeviceAsync_DeviceNull_ShouldReturnNull(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
User user)
{
// Arrange
request.Raw["DeviceIdentifier"] = null;
// Act
var device = await _sut.SaveDeviceAsync(user, request);
// Assert
Assert.Null(device);
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
}
[Theory]
[BitAutoData]
public async void SaveDeviceAsync_UserIsNull_ShouldReturnNull(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
request = AddValidDeviceToRequest(request);
// Act
var device = await _sut.SaveDeviceAsync(null, request);
// Assert
Assert.Null(device);
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
}
[Theory]
[BitAutoData]
public async void SaveDeviceAsync_ExistingUser_NewDevice_ReturnsDevice_SendsEmail(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
User user)
{
// Arrange
request = AddValidDeviceToRequest(request);
user.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
_globalSettings.DisableEmailNewDevice = false;
// Act
var device = await _sut.SaveDeviceAsync(user, request);
// Assert
Assert.NotNull(device);
Assert.Equal(user.Id, device.UserId);
Assert.Equal("DeviceIdentifier", device.Identifier);
Assert.Equal(DeviceType.Android, device.Type);
await _mailService.Received(1).SendNewDeviceLoggedInEmail(
user.Email, "Android", Arg.Any<DateTime>(), Arg.Any<string>());
}
[Theory]
[BitAutoData]
public async void SaveDeviceAsync_ExistingUser_NewDevice_ReturnsDevice_SendEmailFalse(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
User user)
{
// Arrange
request = AddValidDeviceToRequest(request);
user.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
_globalSettings.DisableEmailNewDevice = true;
// Act
var device = await _sut.SaveDeviceAsync(user, request);
// Assert
Assert.NotNull(device);
Assert.Equal(user.Id, device.UserId);
Assert.Equal("DeviceIdentifier", device.Identifier);
Assert.Equal(DeviceType.Android, device.Type);
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
user.Email, "Android", Arg.Any<DateTime>(), Arg.Any<string>());
}
[Theory]
[BitAutoData]
public async void SaveDeviceAsync_DeviceIsKnown_ShouldReturnDevice(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
User user,
Device device) Device device)
{ {
// Arrange // Arrange
request = AddValidDeviceToRequest(request); // AutoData arrages
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);
// Act // Act
var resultDevice = await _sut.SaveDeviceAsync(user, request); var result = await _sut.GetKnownDeviceAsync(null, device);
// Assert // Assert
Assert.Equal(device, resultDevice); Assert.Null(result);
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
} }
[Theory] [Theory, BitAutoData]
[BitAutoData] public async void GetKnownDeviceAsync_DeviceNull_ReturnsFalse(
public async void SaveDeviceAsync_NewUser_DeviceUnknown_ShouldSaveDevice_NoEmail(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
User user)
{
// Arrange
request = AddValidDeviceToRequest(request);
user.CreationDate = DateTime.UtcNow;
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>()).Returns(null as Device);
// Act
var device = await _sut.SaveDeviceAsync(user, request);
// Assert
Assert.NotNull(device);
Assert.Equal(user.Id, device.UserId);
Assert.Equal("DeviceIdentifier", device.Identifier);
Assert.Equal(DeviceType.Android, device.Type);
await _deviceService.Received(1).SaveAsync(device);
await _mailService.DidNotReceive().SendNewDeviceLoggedInEmail(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
}
[Theory]
[BitAutoData]
public async void KnownDeviceAsync_UserNull_ReturnsFalse(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
request = AddValidDeviceToRequest(request);
// Act
var result = await _sut.KnownDeviceAsync(null, request);
// Assert
Assert.False(result);
}
[Theory]
[BitAutoData]
public async void KnownDeviceAsync_DeviceNull_ReturnsFalse(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
User user) User user)
{ {
// Arrange // Arrange
// Device raw data is null which will cause the device to be null // Device raw data is null which will cause the device to be null
// Act // Act
var result = await _sut.KnownDeviceAsync(user, request); var result = await _sut.GetKnownDeviceAsync(user, null);
// Assert // Assert
Assert.False(result); Assert.Null(result);
} }
[Theory] [Theory, BitAutoData]
[BitAutoData] public async void GetKnownDeviceAsync_DeviceNotInDatabase_ReturnsFalse(
public async void KnownDeviceAsync_DeviceNotInDatabase_ReturnsFalse( User user,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, Device device)
User user)
{ {
// Arrange // Arrange
request = AddValidDeviceToRequest(request);
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>()) _deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>())
.Returns(null as Device); .Returns(null as Device);
// Act // Act
var result = await _sut.KnownDeviceAsync(user, request); var result = await _sut.GetKnownDeviceAsync(user, device);
// Assert // Assert
Assert.False(result); Assert.Null(result);
} }
[Theory] [Theory, BitAutoData]
[BitAutoData] public async void GetKnownDeviceAsync_UserAndDeviceValid_ReturnsTrue(
public async void KnownDeviceAsync_UserAndDeviceValid_ReturnsTrue(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
User user, User user,
Device device) Device device)
{ {
// Arrange // Arrange
request = AddValidDeviceToRequest(request); AddValidDeviceToRequest(request);
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>()) _deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>())
.Returns(device); .Returns(device);
// Act // Act
var result = await _sut.KnownDeviceAsync(user, request); var result = await _sut.GetKnownDeviceAsync(user, device);
// Assert
Assert.NotNull(result);
}
[Theory]
[BitAutoData("not null", "Android", "")]
[BitAutoData("not null", "", "not null")]
[BitAutoData("", "Android", "not null")]
public void GetDeviceFromRequest_RawDeviceInfoNull_ReturnsNull(
string deviceIdentifier,
string deviceType,
string deviceName,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
request.Raw["DeviceIdentifier"] = deviceIdentifier;
request.Raw["DeviceType"] = deviceType;
request.Raw["DeviceName"] = deviceName;
// Act
var result = DeviceValidator.GetDeviceFromRequest(request);
// Assert
Assert.Null(result);
}
[Theory, BitAutoData]
public void GetDeviceFromRequest_RawDeviceInfoValid_ReturnsDevice(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
AddValidDeviceToRequest(request);
// Act
var result = DeviceValidator.GetDeviceFromRequest(request);
// Assert
Assert.NotNull(result);
Assert.Equal("DeviceIdentifier", result.Identifier);
Assert.Equal("DeviceName", result.Name);
Assert.Equal(DeviceType.Android, result.Type);
Assert.Equal("DevicePushToken", result.PushToken);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_DeviceNull_ContextModified_ReturnsFalse(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
context.Device = null;
// Act
Assert.NotNull(context.User);
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
Assert.False(result);
Assert.NotNull(context.CustomResponse["ErrorModel"]);
var expectedErrorModel = new ErrorResponseModel("no device information provided");
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
Assert.Equal(expectedErrorModel.Message, actualResponse.Message);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_RequestDeviceKnown_ContextDeviceModified_ReturnsTrue(
Device device,
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
context.Device = null;
AddValidDeviceToRequest(request);
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>())
.Returns(device);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
Assert.True(result);
Assert.False(context.CustomResponse.ContainsKey("ErrorModel"));
Assert.NotNull(context.Device);
Assert.Equal(context.Device, device);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_ContextDeviceKnown_ContextDeviceModified_ReturnsTrue(
Device databaseDevice,
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
_deviceRepository.GetByIdentifierAsync(Arg.Any<string>(), Arg.Any<Guid>())
.Returns(databaseDevice);
// we want to show that the context device is updated when the device is known
Assert.NotEqual(context.Device, databaseDevice);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
Assert.True(result);
Assert.False(context.CustomResponse.ContainsKey("ErrorModel"));
Assert.Equal(context.Device, databaseDevice);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_SendsEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
AddValidDeviceToRequest(request);
_globalSettings.DisableEmailNewDevice = false;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(false);
// set user creation to more than 10 minutes ago
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(1).SaveAsync(context.Device);
await _mailService.Received(1).SendNewDeviceLoggedInEmail(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
Assert.True(result);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_NewUser_DoesNotSendEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
AddValidDeviceToRequest(request);
_globalSettings.DisableEmailNewDevice = false;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(false);
// set user creation to less than 10 minutes ago
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(9);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(1).SaveAsync(context.Device);
await _mailService.Received(0).SendNewDeviceLoggedInEmail(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
Assert.True(result);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_DisableEmailTrue_DoesNotSendEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
AddValidDeviceToRequest(request);
_globalSettings.DisableEmailNewDevice = true;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(false);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(1).SaveAsync(context.Device);
await _mailService.Received(0).SendNewDeviceLoggedInEmail(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<string>());
Assert.True(result);
}
[Theory]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
[BitAutoData("authorization_code")]
[BitAutoData("client_credentials")]
public async void ValidateRequestDeviceAsync_GrantTypeNotPassword_SavesDevice_ReturnsTrue(
string grantType,
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
ArrangeForHandleNewDeviceVerificationTest(context, request);
AddValidDeviceToRequest(request);
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(true);
request.GrantType = grantType;
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(1).SaveAsync(context.Device);
Assert.True(result);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_IsAuthRequest_SavesDevice_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
ArrangeForHandleNewDeviceVerificationTest(context, request);
AddValidDeviceToRequest(request);
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(true);
request.Raw.Add("AuthRequest", "authRequest");
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(1).SaveAsync(context.Device);
Assert.True(result);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_TwoFactorRequired_SavesDevice_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
ArrangeForHandleNewDeviceVerificationTest(context, request);
AddValidDeviceToRequest(request);
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(true);
context.TwoFactorRequired = true;
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(1).SaveAsync(context.Device);
Assert.True(result);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_SsoRequired_SavesDevice_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
ArrangeForHandleNewDeviceVerificationTest(context, request);
AddValidDeviceToRequest(request);
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(true);
context.SsoRequired = true;
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(1).SaveAsync(context.Device);
Assert.True(result);
}
[Theory, BitAutoData]
public async void HandleNewDeviceVerificationAsync_UserNull_ContextModified_ReturnsInvalidUser(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
ArrangeForHandleNewDeviceVerificationTest(context, request);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
_globalSettings.EnableNewDeviceVerification = true;
context.User = null;
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
Assert.False(result);
Assert.NotNull(context.CustomResponse["ErrorModel"]);
// PM-13340: The error message should be "invalid user" instead of "no device information provided"
var expectedErrorMessage = "no device information provided";
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
Assert.Equal(expectedErrorMessage, actualResponse.Message);
}
[Theory, BitAutoData]
public async void HandleNewDeviceVerificationAsync_NewDeviceOtpValid_ReturnsSuccess(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
ArrangeForHandleNewDeviceVerificationTest(context, request);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
_globalSettings.EnableNewDeviceVerification = true;
var newDeviceOtp = "123456";
request.Raw.Add("NewDeviceOtp", newDeviceOtp);
_userService.VerifyOTPAsync(context.User, newDeviceOtp).Returns(true);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _userService.Received(0).SendOTPAsync(context.User);
await _deviceService.Received(1).SaveAsync(context.Device);
Assert.True(result);
Assert.False(context.CustomResponse.ContainsKey("ErrorModel"));
Assert.Equal(context.User.Id, context.Device.UserId);
Assert.NotNull(context.Device);
}
[Theory]
[BitAutoData("")]
[BitAutoData("123456")]
public async void HandleNewDeviceVerificationAsync_NewDeviceOtpInvalid_ReturnsInvalidNewDeviceOtp(
string newDeviceOtp,
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
ArrangeForHandleNewDeviceVerificationTest(context, request);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
_globalSettings.EnableNewDeviceVerification = true;
request.Raw.Add("NewDeviceOtp", newDeviceOtp);
_userService.VerifyOTPAsync(context.User, newDeviceOtp).Returns(false);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _userService.DidNotReceive().SendOTPAsync(Arg.Any<User>());
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
Assert.False(result);
Assert.NotNull(context.CustomResponse["ErrorModel"]);
var expectedErrorMessage = "invalid new device otp";
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
Assert.Equal(expectedErrorMessage, actualResponse.Message);
}
[Theory, BitAutoData]
public async void HandleNewDeviceVerificationAsync_UserHasNoDevices_ReturnsSuccess(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
ArrangeForHandleNewDeviceVerificationTest(context, request);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
_globalSettings.EnableNewDeviceVerification = true;
_deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([]);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _userService.Received(0).VerifyOTPAsync(Arg.Any<User>(), Arg.Any<string>());
await _userService.Received(0).SendOTPAsync(Arg.Any<User>());
await _deviceService.Received(1).SaveAsync(context.Device);
Assert.True(result);
Assert.False(context.CustomResponse.ContainsKey("ErrorModel"));
Assert.Equal(context.User.Id, context.Device.UserId);
Assert.NotNull(context.Device);
}
[Theory, BitAutoData]
public async void HandleNewDeviceVerificationAsync_NewDeviceOtpEmpty_UserHasDevices_ReturnsNewDeviceVerificationRequired(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
ArrangeForHandleNewDeviceVerificationTest(context, request);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true);
_globalSettings.EnableNewDeviceVerification = true;
_deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([new Device()]);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _userService.Received(1).SendOTPAsync(context.User);
await _deviceService.Received(0).SaveAsync(Arg.Any<Device>());
Assert.False(result);
Assert.NotNull(context.CustomResponse["ErrorModel"]);
var expectedErrorMessage = "new device verification required";
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
Assert.Equal(expectedErrorMessage, actualResponse.Message);
}
[Theory, BitAutoData]
public void NewDeviceOtpRequest_NewDeviceOtpNull_ReturnsFalse(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
// Autodata arranges
// Act
var result = DeviceValidator.NewDeviceOtpRequest(request);
// Assert
Assert.False(result);
}
[Theory, BitAutoData]
public void NewDeviceOtpRequest_NewDeviceOtpNotNull_ReturnsTrue(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
request.Raw["NewDeviceOtp"] = "123456";
// Act
var result = DeviceValidator.NewDeviceOtpRequest(request);
// Assert // Assert
Assert.True(result); Assert.True(result);
} }
private ValidatedTokenRequest AddValidDeviceToRequest(ValidatedTokenRequest request) private static void AddValidDeviceToRequest(ValidatedTokenRequest request)
{ {
request.Raw["DeviceIdentifier"] = "DeviceIdentifier"; request.Raw["DeviceIdentifier"] = "DeviceIdentifier";
request.Raw["DeviceType"] = "Android"; request.Raw["DeviceType"] = "Android"; // must be valid device type
request.Raw["DeviceName"] = "DeviceName"; request.Raw["DeviceName"] = "DeviceName";
request.Raw["DevicePushToken"] = "DevicePushToken"; request.Raw["DevicePushToken"] = "DevicePushToken";
return request; }
/// <summary>
/// Configures the request context to facilitate testing the HandleNewDeviceVerificationAsync method.
/// </summary>
/// <param name="context">test context</param>
/// <param name="request">test request</param>
private static void ArrangeForHandleNewDeviceVerificationTest(
CustomValidatorRequestContext context,
ValidatedTokenRequest request)
{
context.KnownDevice = false;
request.GrantType = "password";
context.TwoFactorRequired = false;
context.SsoRequired = false;
} }
} }

View File

@ -123,6 +123,11 @@ IBaseRequestValidatorTestWrapper
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ } { }
protected override void SetValidationErrorResult(
BaseRequestValidationContextFake context,
CustomValidatorRequestContext requestContext)
{ }
protected override Task<bool> ValidateContextAsync( protected override Task<bool> ValidateContextAsync(
BaseRequestValidationContextFake context, BaseRequestValidationContextFake context,
CustomValidatorRequestContext validatorContext) CustomValidatorRequestContext validatorContext)