mirror of
https://github.com/bitwarden/server.git
synced 2025-12-10 00:42:07 -06:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375af7c43b | ||
|
|
8ae052039f | ||
|
|
7090cc799d | ||
|
|
40f04ab832 | ||
|
|
ed42d4bec8 | ||
|
|
963528106b | ||
|
|
082acb7500 | ||
|
|
86c1d642c9 |
@ -3,61 +3,40 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.7.2</Version>
|
||||
<Version>2025.7.3</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<!-- Treat it as a test project if the project hasn't set their own value and it follows our test project conventions -->
|
||||
|
||||
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
|
||||
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
|
||||
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
|
||||
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
This section is for packages that we use multiple times throughout the solution
|
||||
It gives us a single place to manage the version to ensure we are using the same version
|
||||
across the solution.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk
|
||||
-->
|
||||
|
||||
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/xunit
|
||||
-->
|
||||
|
||||
<XUnitVersion>2.6.6</XUnitVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/xunit.runner.visualstudio
|
||||
-->
|
||||
|
||||
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/coverlet.collector
|
||||
-->
|
||||
|
||||
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/NSubstitute
|
||||
-->
|
||||
|
||||
<NSubstituteVersion>5.1.0</NSubstituteVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2
|
||||
-->
|
||||
|
||||
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute
|
||||
-->
|
||||
|
||||
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
This section is for getting & setting the gitHash value, which can easily be accessed
|
||||
via the Core.Utilities.AssemblyHelpers class.
|
||||
-->
|
||||
|
||||
<Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
|
||||
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False">
|
||||
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput"/>
|
||||
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput" />
|
||||
</Exec>
|
||||
</Target>
|
||||
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId">
|
||||
|
||||
@ -255,9 +255,12 @@ public class AccountController : Controller
|
||||
_logger.LogDebug("External claims: {@claims}", externalClaims);
|
||||
|
||||
// Lookup our user and external provider info
|
||||
// Note: the user will only exist if the user has already been provisioned and exists in the User table and the SSO user table.
|
||||
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
|
||||
if (user == null)
|
||||
{
|
||||
// User does not exist in SSO User table. They could have an existing BW account in the User table.
|
||||
|
||||
// This might be where you might initiate a custom workflow for user registration
|
||||
// in this sample we don't show how that would be done, as our sample implementation
|
||||
// simply auto-provisions new external user
|
||||
@ -268,6 +271,8 @@ public class AccountController : Controller
|
||||
|
||||
if (user != null)
|
||||
{
|
||||
// User was JIT provisioned (this could be an existing user or a new user)
|
||||
|
||||
// This allows us to collect any additional claims or properties
|
||||
// for the specific protocols used and store them in the local auth cookie.
|
||||
// this is typically used to store data needed for signout from those protocols.
|
||||
@ -487,12 +492,8 @@ public class AccountController : Controller
|
||||
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
|
||||
}
|
||||
|
||||
if (orgUser.Status == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
// Org User is invited - they must manually accept the invite via email and authenticate with MP
|
||||
// This allows us to enroll them in MP reset if required
|
||||
throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
|
||||
}
|
||||
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(),
|
||||
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
|
||||
|
||||
// Accepted or Confirmed - create SSO link and return;
|
||||
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
|
||||
@ -587,6 +588,36 @@ public class AccountController : Controller
|
||||
return user;
|
||||
}
|
||||
|
||||
private void EnsureOrgUserStatusAllowed(
|
||||
OrganizationUserStatusType status,
|
||||
string organizationDisplayName,
|
||||
params OrganizationUserStatusType[] allowedStatuses)
|
||||
{
|
||||
// if this status is one of the allowed ones, just return
|
||||
if (allowedStatuses.Contains(status))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise throw the appropriate exception
|
||||
switch (status)
|
||||
{
|
||||
case OrganizationUserStatusType.Invited:
|
||||
// Org User is invited – must accept via email first
|
||||
throw new Exception(
|
||||
_i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName));
|
||||
case OrganizationUserStatusType.Revoked:
|
||||
// Revoked users may not be (auto)‑provisioned
|
||||
throw new Exception(
|
||||
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName));
|
||||
default:
|
||||
// anything else is “unknown”
|
||||
throw new Exception(
|
||||
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
|
||||
{
|
||||
Response.StatusCode = ex == null ? 400 : 500;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
#nullable enable
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Payment.Commands;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Utilities;
|
||||
@ -19,6 +19,7 @@ public class ProviderBillingVNextController(
|
||||
IGetBillingAddressQuery getBillingAddressQuery,
|
||||
IGetCreditQuery getCreditQuery,
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
IProviderService providerService,
|
||||
IUpdateBillingAddressCommand updateBillingAddressCommand,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||
IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController
|
||||
@ -82,6 +83,15 @@ public class ProviderBillingVNextController(
|
||||
{
|
||||
var (paymentMethod, billingAddress) = request.ToDomain();
|
||||
var result = await updatePaymentMethodCommand.Run(provider, paymentMethod, billingAddress);
|
||||
// TODO: Temporary until we can send Provider notifications from the Billing API
|
||||
if (!provider.Enabled)
|
||||
{
|
||||
await result.TapAsync(async _ =>
|
||||
{
|
||||
provider.Enabled = true;
|
||||
await providerService.UpdateAsync(provider);
|
||||
});
|
||||
}
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
|
||||
@ -40,5 +40,6 @@ public class BillingSettings
|
||||
{
|
||||
public virtual string ApiKey { get; set; }
|
||||
public virtual string BaseUrl { get; set; }
|
||||
public virtual int PersonaId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,7 +153,7 @@ public class FreshdeskController : Controller
|
||||
}
|
||||
|
||||
// create the onyx `answer-with-citation` request
|
||||
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText);
|
||||
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId);
|
||||
var onyxRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
|
||||
{
|
||||
|
||||
@ -20,11 +20,12 @@ public class OnyxAnswerWithCitationRequestModel
|
||||
[JsonPropertyName("retrieval_options")]
|
||||
public RetrievalOptions RetrievalOptions { get; set; }
|
||||
|
||||
public OnyxAnswerWithCitationRequestModel(string message)
|
||||
public OnyxAnswerWithCitationRequestModel(string message, int personaId = 1)
|
||||
{
|
||||
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
Messages = new List<Message>() { new Message() { MessageText = message } };
|
||||
RetrievalOptions = new RetrievalOptions();
|
||||
PersonaId = personaId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,5 +31,10 @@
|
||||
"storage": {
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
}
|
||||
},
|
||||
"billingSettings": {
|
||||
"onyx": {
|
||||
"personaId": 68
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,10 @@
|
||||
"payPal": {
|
||||
"production": true,
|
||||
"businessId": "4ZDA7DLUUJGMN"
|
||||
}
|
||||
},
|
||||
"onyx": {
|
||||
"personaId": 7
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
|
||||
@ -76,7 +76,8 @@
|
||||
},
|
||||
"onyx": {
|
||||
"apiKey": "SECRET",
|
||||
"baseUrl": "https://cloud.onyx.app/api"
|
||||
"baseUrl": "https://cloud.onyx.app/api",
|
||||
"personaId": 7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
@ -43,11 +44,31 @@ public class AuthRequest : ITableObject<Guid>
|
||||
|
||||
public bool IsSpent()
|
||||
{
|
||||
return ResponseDate.HasValue || AuthenticationDate.HasValue || GetExpirationDate() < DateTime.UtcNow;
|
||||
return ResponseDate.HasValue || AuthenticationDate.HasValue || IsExpired();
|
||||
}
|
||||
|
||||
public bool IsExpired()
|
||||
{
|
||||
// TODO: PM-24252 - consider using TimeProvider for better mocking in tests
|
||||
return GetExpirationDate() < DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// TODO: PM-24252 - this probably belongs in a service.
|
||||
public bool IsValidForAuthentication(Guid userId,
|
||||
string password)
|
||||
{
|
||||
return ResponseDate.HasValue // it’s been responded to
|
||||
&& Approved == true // it was approved
|
||||
&& !IsExpired() // it's not expired
|
||||
&& Type == AuthRequestType.AuthenticateAndUnlock // it’s an authN request
|
||||
&& !AuthenticationDate.HasValue // it was not already used for authN
|
||||
&& UserId == userId // it belongs to the user
|
||||
&& CoreHelpers.FixedTimeEquals(AccessCode, password); // the access code matches the password
|
||||
}
|
||||
|
||||
public DateTime GetExpirationDate()
|
||||
{
|
||||
// TODO: PM-24252 - this should reference PasswordlessAuthSettings.UserRequestExpiration
|
||||
return CreationDate.AddMinutes(15);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,4 +28,10 @@ public class BillingCommandResult<T> : OneOfBase<T, BadRequest, Conflict, Unhand
|
||||
public static implicit operator BillingCommandResult<T>(BadRequest badRequest) => new(badRequest);
|
||||
public static implicit operator BillingCommandResult<T>(Conflict conflict) => new(conflict);
|
||||
public static implicit operator BillingCommandResult<T>(Unhandled unhandled) => new(unhandled);
|
||||
|
||||
public Task TapAsync(Func<T, Task> f) => Match(
|
||||
f,
|
||||
_ => Task.CompletedTask,
|
||||
_ => Task.CompletedTask,
|
||||
_ => Task.CompletedTask);
|
||||
}
|
||||
|
||||
@ -532,6 +532,12 @@
|
||||
<data name="AcceptInviteBeforeUsingSSO" xml:space="preserve">
|
||||
<value>To accept your invite to {0}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.</value>
|
||||
</data>
|
||||
<data name="OrganizationUserAccessRevoked" xml:space="preserve">
|
||||
<value>Your access to organization {0} has been revoked. Please contact your administrator for assistance.</value>
|
||||
</data>
|
||||
<data name="OrganizationUserUnknownStatus" xml:space="preserve">
|
||||
<value>Your access to organization {0} is in an unknown state. Please contact your administrator for assistance.</value>
|
||||
</data>
|
||||
<data name="UserAlreadyExistsInviteProcess" xml:space="preserve">
|
||||
<value>You were removed from the organization managing single sign-on for your account. Contact the organization administrator for help regaining access to your account.</value>
|
||||
</data>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
@ -41,4 +42,10 @@ public class CustomValidatorRequestContext
|
||||
/// This will be null if the authentication request is successful.
|
||||
/// </summary>
|
||||
public Dictionary<string, object> CustomResponse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A validated auth request
|
||||
/// <see cref="AuthRequest.IsValidForAuthentication"/>
|
||||
/// </summary>
|
||||
public AuthRequest ValidatedAuthRequest { get; set; }
|
||||
}
|
||||
|
||||
@ -6,5 +6,6 @@ public enum DeviceValidationResultType : byte
|
||||
InvalidUser = 1,
|
||||
InvalidNewDeviceOtp = 2,
|
||||
NewDeviceVerificationRequired = 3,
|
||||
NoDeviceInformationProvided = 4
|
||||
NoDeviceInformationProvided = 4,
|
||||
AuthRequestFlowUnknownDevice = 5,
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
private readonly ILogger _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
|
||||
protected ICurrentContext CurrentContext { get; }
|
||||
protected IPolicyService PolicyService { get; }
|
||||
@ -59,7 +60,9 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||
IPolicyRequirementQuery policyRequirementQuery)
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAuthRequestRepository authRequestRepository
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userService = userService;
|
||||
@ -76,6 +79,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
SsoConfigRepository = ssoConfigRepository;
|
||||
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
|
||||
PolicyRequirementQuery = policyRequirementQuery;
|
||||
_authRequestRepository = authRequestRepository;
|
||||
}
|
||||
|
||||
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
||||
@ -190,6 +194,14 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: PM-24324 - This should be its own validator at some point.
|
||||
// 6. Auth request handling
|
||||
if (validatorContext.ValidatedAuthRequest != null)
|
||||
{
|
||||
validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow;
|
||||
await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest);
|
||||
}
|
||||
|
||||
await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
|
||||
}
|
||||
|
||||
@ -404,8 +416,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
/// <summary>
|
||||
/// Builds the custom response that will be sent to the client upon successful authentication, which
|
||||
/// includes the information needed for the client to initialize the user's account in state.
|
||||
/// </summary>
|
||||
/// <param name="user">The authenticated user.</param>
|
||||
/// </summary>
|
||||
/// <param name="user">The authenticated user.</param>
|
||||
/// <param name="context">The current request context.</param>
|
||||
/// <param name="device">The device used for authentication.</param>
|
||||
/// <param name="sendRememberToken">Whether to send a 2FA remember token.</param>
|
||||
|
||||
@ -45,7 +45,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||
IUpdateInstallationCommand updateInstallationCommand,
|
||||
IPolicyRequirementQuery policyRequirementQuery)
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAuthRequestRepository authRequestRepository)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
@ -61,7 +62,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
featureService,
|
||||
ssoConfigRepository,
|
||||
userDecryptionOptionsBuilder,
|
||||
policyRequirementQuery)
|
||||
policyRequirementQuery,
|
||||
authRequestRepository)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_updateInstallationCommand = updateInstallationCommand;
|
||||
|
||||
@ -39,6 +39,8 @@ public class DeviceValidator(
|
||||
private readonly ILogger<DeviceValidator> _logger = logger;
|
||||
private readonly ITwoFactorEmailService _twoFactorEmailService = twoFactorEmailService;
|
||||
|
||||
private const string PasswordGrantType = "password";
|
||||
|
||||
public async Task<bool> ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context)
|
||||
{
|
||||
// Parse device from request and return early if no device information is provided
|
||||
@ -68,10 +70,14 @@ public class DeviceValidator(
|
||||
}
|
||||
|
||||
// We have established that the device is unknown at this point; begin new device verification
|
||||
if (request.GrantType == "password" &&
|
||||
request.Raw["AuthRequest"] == null &&
|
||||
!context.TwoFactorRequired &&
|
||||
!context.SsoRequired &&
|
||||
// for standard password grant type requests
|
||||
// Note: the auth request flow re-uses the resource owner password flow but new device verification
|
||||
// is not required for auth requests
|
||||
var rawAuthRequestId = request.Raw["AuthRequest"]?.ToLowerInvariant();
|
||||
var isAuthRequest = !string.IsNullOrEmpty(rawAuthRequestId);
|
||||
if (request.GrantType == PasswordGrantType &&
|
||||
!isAuthRequest &&
|
||||
context is { TwoFactorRequired: false, SsoRequired: false } &&
|
||||
_globalSettings.EnableNewDeviceVerification)
|
||||
{
|
||||
var validationResult = await HandleNewDeviceVerificationAsync(context.User, request);
|
||||
@ -87,6 +93,15 @@ public class DeviceValidator(
|
||||
}
|
||||
}
|
||||
|
||||
// Device still unknown, but if we are in an auth request flow, this is not valid
|
||||
// as we only support auth request authN requests on known devices
|
||||
if (request.GrantType == PasswordGrantType && isAuthRequest)
|
||||
{
|
||||
(context.ValidationErrorResult, context.CustomResponse) =
|
||||
BuildDeviceErrorResult(DeviceValidationResultType.AuthRequestFlowUnknownDevice);
|
||||
return false;
|
||||
}
|
||||
|
||||
// At this point we have established either new device verification is not required or the NewDeviceOtp is valid,
|
||||
// so we save the device to the database and proceed with authentication
|
||||
requestDevice.UserId = context.User.Id;
|
||||
@ -252,7 +267,7 @@ public class DeviceValidator(
|
||||
var customResponse = new Dictionary<string, object>();
|
||||
switch (errorType)
|
||||
{
|
||||
/*
|
||||
/*
|
||||
* The ErrorMessage is brittle and is used to control the flow in the clients. Do not change them without updating the client as well.
|
||||
* There is a backwards compatibility issue as well: if you make a change on the clients then ensure that they are backwards
|
||||
* compatible.
|
||||
@ -273,6 +288,10 @@ public class DeviceValidator(
|
||||
result.ErrorDescription = "No device information provided";
|
||||
customResponse.Add("ErrorModel", new ErrorResponseModel("no device information provided"));
|
||||
break;
|
||||
case DeviceValidationResultType.AuthRequestFlowUnknownDevice:
|
||||
result.ErrorDescription = "Auth requests are not supported on unknown devices";
|
||||
customResponse.Add("ErrorModel", new ErrorResponseModel("auth request flow unsupported on unknown device"));
|
||||
break;
|
||||
}
|
||||
return (result, customResponse);
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -57,7 +56,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
featureService,
|
||||
ssoConfigRepository,
|
||||
userDecryptionOptionsBuilder,
|
||||
policyRequirementQuery)
|
||||
policyRequirementQuery,
|
||||
authRequestRepository)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_currentContext = currentContext;
|
||||
@ -90,21 +90,33 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
return false;
|
||||
}
|
||||
|
||||
var authRequestId = context.Request.Raw["AuthRequest"]?.ToString()?.ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(authRequestId) && Guid.TryParse(authRequestId, out var authRequestGuid))
|
||||
var authRequestId = context.Request.Raw["AuthRequest"]?.ToLowerInvariant();
|
||||
if (!string.IsNullOrEmpty(authRequestId))
|
||||
{
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestGuid);
|
||||
if (authRequest != null)
|
||||
// only allow valid guids
|
||||
if (!Guid.TryParse(authRequestId, out var authRequestGuid))
|
||||
{
|
||||
var requestAge = DateTime.UtcNow - authRequest.CreationDate;
|
||||
if (requestAge < TimeSpan.FromHours(1) &&
|
||||
CoreHelpers.FixedTimeEquals(authRequest.AccessCode, context.Password))
|
||||
{
|
||||
authRequest.AuthenticationDate = DateTime.UtcNow;
|
||||
await _authRequestRepository.ReplaceAsync(authRequest);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestGuid);
|
||||
|
||||
if (authRequest == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auth request is non-null so validate it
|
||||
if (authRequest.IsValidForAuthentication(validatorContext.User.Id, context.Password))
|
||||
{
|
||||
// We save the validated auth request so that we can set it's authentication date
|
||||
// later on only upon successful authentication.
|
||||
// For example, 2FA requires a resubmission so we can't mark the auth request
|
||||
// as authenticated here.
|
||||
validatorContext.ValidatedAuthRequest = authRequest;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -48,7 +48,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
IFeatureService featureService,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand,
|
||||
IPolicyRequirementQuery policyRequirementQuery)
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAuthRequestRepository authRequestRepository)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
@ -64,7 +65,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
featureService,
|
||||
ssoConfigRepository,
|
||||
userDecryptionOptionsBuilder,
|
||||
policyRequirementQuery)
|
||||
policyRequirementQuery,
|
||||
authRequestRepository)
|
||||
{
|
||||
_assertionOptionsDataProtector = assertionOptionsDataProtector;
|
||||
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
|
||||
|
||||
@ -16,16 +16,18 @@ namespace Bit.Identity.IntegrationTest.RequestValidation;
|
||||
|
||||
public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplicationFactory>
|
||||
{
|
||||
private const string DefaultPassword = "master_password_hash";
|
||||
private const string DefaultUsername = "test@email.qa";
|
||||
private const string DefaultDeviceIdentifier = "test_identifier";
|
||||
private const string _defaultPassword = "master_password_hash";
|
||||
private const string _defaultUsername = "test@email.qa";
|
||||
private const string _defaultDeviceIdentifier = "test_identifier";
|
||||
private const DeviceType _defaultDeviceType = DeviceType.FirefoxBrowser;
|
||||
private const string _defaultDeviceName = "firefox";
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_Success()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
await EnsureUserCreatedAsync(localFactory);
|
||||
await RegisterUserAsync(localFactory);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
@ -74,12 +76,12 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
await EnsureUserCreatedAsync(localFactory);
|
||||
await RegisterUserAsync(localFactory);
|
||||
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
|
||||
// Verify the User is not null to ensure the failure is due to bad password
|
||||
Assert.NotNull(await userManager.FindByEmailAsync(DefaultUsername));
|
||||
Assert.NotNull(await userManager.FindByEmailAsync(_defaultUsername));
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
@ -95,23 +97,142 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidateContextAsync_AuthRequest_NotNull_AgeLessThanOneHour_Success()
|
||||
public async Task ValidateAsync_ValidateContextAsync_ValidAuthRequest_Success()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
|
||||
// Ensure User
|
||||
await EnsureUserCreatedAsync(localFactory);
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
var user = await userManager.FindByEmailAsync(DefaultUsername);
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Connect Request to User and set CreationDate
|
||||
var authRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-30)
|
||||
);
|
||||
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
|
||||
await AddKnownDevice(localFactory, user.Id);
|
||||
|
||||
// Create valid auth request and tie it to the user
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
|
||||
r.Approved = true; // approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // not expired
|
||||
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
|
||||
r.AuthenticationDate = null; // not used for authN yet
|
||||
r.UserId = user.Id; // connect request to user
|
||||
r.AccessCode = _defaultPassword; // matches the password
|
||||
});
|
||||
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
|
||||
Assert.NotEmpty(expectedAuthRequest);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(_defaultDeviceType) },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", _defaultDeviceName },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = body.RootElement;
|
||||
|
||||
var token = AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String).GetString();
|
||||
Assert.NotNull(token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidateContextAsync_ValidAuthRequest_UnknownDevice_Failure()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
|
||||
// Ensure User
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Create valid auth request and tie it to the user
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
|
||||
r.Approved = true; // approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // not expired
|
||||
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
|
||||
r.AuthenticationDate = null; // not used for authN yet
|
||||
r.UserId = user.Id; // connect request to user
|
||||
r.AccessCode = _defaultPassword; // matches the password
|
||||
});
|
||||
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
|
||||
Assert.NotEmpty(expectedAuthRequest);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(_defaultDeviceType) },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", _defaultDeviceName },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// 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("auth request flow unsupported on unknown device", errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidateContextAsync_Expired_AuthRequest_Failure()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
// Ensure User
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
|
||||
await AddKnownDevice(localFactory, user.Id);
|
||||
|
||||
// Create AuthRequest
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = DateTime.UtcNow.AddMinutes(-10); // 10 minutes ago
|
||||
r.Approved = true; // approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-16); // expired after 15 minutes
|
||||
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
|
||||
r.AuthenticationDate = null; // not used for authN yet
|
||||
r.UserId = user.Id; // connect request to user
|
||||
r.AccessCode = _defaultPassword; // matches the password
|
||||
});
|
||||
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
@ -125,40 +246,51 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
|
||||
{ "deviceIdentifier", DefaultDeviceIdentifier },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", DefaultUsername },
|
||||
{ "password", DefaultPassword },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = body.RootElement;
|
||||
|
||||
var token = AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String).GetString();
|
||||
Assert.NotNull(token);
|
||||
await AssertStandardError(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidateContextAsync_AuthRequest_NotNull_AgeGreaterThanOneHour_Failure()
|
||||
public async Task ValidateAsync_ValidateContextAsync_Unapproved_AuthRequest_Failure()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
// Ensure User
|
||||
await EnsureUserCreatedAsync(localFactory);
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
|
||||
var user = await userManager.FindByEmailAsync(DefaultUsername);
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
|
||||
await AddKnownDevice(localFactory, user.Id);
|
||||
|
||||
// Create AuthRequest
|
||||
var authRequest = CreateAuthRequest(
|
||||
user.Id,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
DateTime.UtcNow.AddMinutes(-61)
|
||||
);
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
|
||||
r.Approved = false; // NOT approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
|
||||
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
|
||||
r.AuthenticationDate = null; // not used for authN yet
|
||||
r.UserId = user.Id; // connect request to user
|
||||
r.AccessCode = _defaultPassword; // matches the password
|
||||
});
|
||||
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
|
||||
Assert.NotEmpty(expectedAuthRequest);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
@ -167,22 +299,137 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
|
||||
{ "deviceIdentifier", DefaultDeviceIdentifier },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", DefaultUsername },
|
||||
{ "password", DefaultPassword },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
|
||||
/*
|
||||
An improvement on the current failure flow would be to document which part of
|
||||
the flow failed since all of the failures are basically the same.
|
||||
This doesn't build confidence in the tests.
|
||||
*/
|
||||
await AssertStandardError(context);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> InvalidAuthRequestTypes()
|
||||
{
|
||||
// yield the two enum values that should fail
|
||||
yield return [AuthRequestType.Unlock];
|
||||
yield return [AuthRequestType.AdminApproval];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidAuthRequestTypes))]
|
||||
public async Task ValidateAsync_ValidateContextAsync_AuthRequest_Invalid_Type_Failure(AuthRequestType invalidType)
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
// Ensure User
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
|
||||
await AddKnownDevice(localFactory, user.Id);
|
||||
|
||||
// Create AuthRequest
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
|
||||
r.Approved = true; // approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
|
||||
r.Type = invalidType; // invalid type for authN
|
||||
r.AuthenticationDate = null; // not used for authN yet
|
||||
r.UserId = user.Id; // connect request to user
|
||||
r.AccessCode = _defaultPassword; // matches the password
|
||||
});
|
||||
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
|
||||
Assert.NotEmpty(expectedAuthRequest);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
|
||||
await AssertStandardError(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidateContextAsync_AuthRequest_WrongUser_Failure()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
|
||||
// Ensure User 1 exists
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
|
||||
|
||||
// Ensure User 2 exists so we can satisfy auth request foreign key constraint
|
||||
var user2Username = "user2@email.com";
|
||||
var user2Password = "user2_password";
|
||||
await RegisterUserAsync(localFactory, user2Username, user2Password);
|
||||
var user2 = await userManager.FindByEmailAsync(user2Username);
|
||||
Assert.NotNull(user2);
|
||||
|
||||
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
|
||||
await AddKnownDevice(localFactory, user.Id);
|
||||
|
||||
// Create valid auth request for user 2
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
|
||||
r.Approved = true; // approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // not expired
|
||||
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
|
||||
r.AuthenticationDate = null; // not used for authN yet
|
||||
r.UserId = user2.Id; // connect request to user2
|
||||
r.AccessCode = _defaultPassword; // matches the password
|
||||
});
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user2.Id);
|
||||
Assert.NotEmpty(expectedAuthRequest);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(_defaultDeviceType) },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", _defaultDeviceName },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
var root = body.RootElement;
|
||||
|
||||
@ -191,17 +438,180 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
Assert.Equal("Username or password is incorrect. Try again.", errorMessage);
|
||||
}
|
||||
|
||||
private async Task EnsureUserCreatedAsync(IdentityApplicationFactory factory)
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidateContextAsync_Unanswered_AuthRequest_Failure()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
// Ensure User
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
|
||||
await AddKnownDevice(localFactory, user.Id);
|
||||
|
||||
// Create AuthRequest
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = null; // not answered
|
||||
r.Approved = true; // approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
|
||||
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
|
||||
r.AuthenticationDate = null; // not used for authN yet
|
||||
r.UserId = user.Id; // connect request to user
|
||||
r.AccessCode = _defaultPassword; // matches the password
|
||||
});
|
||||
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
|
||||
Assert.NotEmpty(expectedAuthRequest);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
|
||||
await AssertStandardError(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidateContextAsync_WrongPassword_AuthRequest_Failure()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
// Ensure User
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
|
||||
await AddKnownDevice(localFactory, user.Id);
|
||||
|
||||
// Create AuthRequest
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // answered
|
||||
r.Approved = true; // approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
|
||||
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
|
||||
r.AuthenticationDate = null; // not used for authN yet
|
||||
r.UserId = user.Id; // connect request to user
|
||||
r.AccessCode = "WRONG_BAD_PASSWORD"; // does not match the password
|
||||
});
|
||||
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
|
||||
Assert.NotEmpty(expectedAuthRequest);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
|
||||
await AssertStandardError(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ValidateContextAsync_Spent_AuthRequest_Failure()
|
||||
{
|
||||
// Arrange
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
// Ensure User
|
||||
await RegisterUserAsync(localFactory);
|
||||
var userManager = localFactory.GetService<UserManager<User>>();
|
||||
|
||||
var user = await userManager.FindByEmailAsync(_defaultUsername);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
|
||||
await AddKnownDevice(localFactory, user.Id);
|
||||
|
||||
// Create AuthRequest
|
||||
var authRequest = CreateAuthRequest(r =>
|
||||
{
|
||||
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // answered
|
||||
r.Approved = true; // approved
|
||||
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
|
||||
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
|
||||
r.AuthenticationDate = DateTime.UtcNow.AddMinutes(-2); // spent request - already has been used for authN
|
||||
r.UserId = user.Id; // connect request to user
|
||||
r.AccessCode = _defaultPassword; // does not match the password
|
||||
});
|
||||
|
||||
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
|
||||
await authRequestRepository.CreateAsync(authRequest);
|
||||
|
||||
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
|
||||
Assert.NotEmpty(expectedAuthRequest);
|
||||
|
||||
// Act
|
||||
var context = await localFactory.Server.PostAsync("/connect/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
|
||||
{ "deviceIdentifier", _defaultDeviceIdentifier },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
|
||||
}));
|
||||
|
||||
// Assert
|
||||
await AssertStandardError(context);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task RegisterUserAsync(
|
||||
IdentityApplicationFactory factory,
|
||||
string username = _defaultUsername,
|
||||
string password = _defaultPassword
|
||||
)
|
||||
{
|
||||
// Register user
|
||||
await factory.RegisterNewIdentityFactoryUserAsync(
|
||||
new RegisterFinishRequestModel
|
||||
{
|
||||
Email = DefaultUsername,
|
||||
MasterPasswordHash = DefaultPassword,
|
||||
Email = username,
|
||||
MasterPasswordHash = password,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
UserAsymmetricKeys = new KeysRequestModel()
|
||||
UserAsymmetricKeys = new KeysRequestModel
|
||||
{
|
||||
PublicKey = "public_key",
|
||||
EncryptedPrivateKey = "private_key"
|
||||
@ -218,11 +628,11 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
|
||||
{ "deviceIdentifier", deviceId ?? DefaultDeviceIdentifier },
|
||||
{ "deviceIdentifier", deviceId ?? _defaultDeviceIdentifier },
|
||||
{ "deviceName", "firefox" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", username ?? DefaultUsername },
|
||||
{ "password", password ?? DefaultPassword },
|
||||
{ "username", username ?? _defaultUsername },
|
||||
{ "password", password ?? _defaultPassword },
|
||||
});
|
||||
}
|
||||
|
||||
@ -233,8 +643,8 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
{ "scope", "api offline_access" },
|
||||
{ "client_id", "web" },
|
||||
{ "grant_type", "password" },
|
||||
{ "username", DefaultUsername },
|
||||
{ "password", DefaultPassword },
|
||||
{ "username", _defaultUsername },
|
||||
{ "password", _defaultPassword },
|
||||
});
|
||||
}
|
||||
|
||||
@ -243,24 +653,53 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
return ((int)deviceType).ToString();
|
||||
}
|
||||
|
||||
private static AuthRequest CreateAuthRequest(
|
||||
Guid userId,
|
||||
AuthRequestType authRequestType,
|
||||
DateTime creationDate,
|
||||
bool? approved = null,
|
||||
DateTime? responseDate = null)
|
||||
|
||||
private AuthRequest CreateAuthRequest(Action<AuthRequest>? customize = null)
|
||||
{
|
||||
return new AuthRequest
|
||||
var req = new AuthRequest
|
||||
{
|
||||
UserId = userId,
|
||||
Type = authRequestType,
|
||||
Approved = approved,
|
||||
RequestDeviceIdentifier = DefaultDeviceIdentifier,
|
||||
// required fields with defaults
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
RequestDeviceIdentifier = _defaultDeviceIdentifier,
|
||||
RequestIpAddress = "1.1.1.1",
|
||||
AccessCode = DefaultPassword,
|
||||
AccessCode = _defaultPassword,
|
||||
PublicKey = "test_public_key",
|
||||
CreationDate = creationDate,
|
||||
ResponseDate = responseDate,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// let the caller tweak whatever they need
|
||||
customize?.Invoke(req);
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
private async Task AddKnownDevice(IdentityApplicationFactory factory, Guid userId)
|
||||
{
|
||||
var userDevice = new Device
|
||||
{
|
||||
Identifier = _defaultDeviceIdentifier,
|
||||
Type = _defaultDeviceType,
|
||||
Name = _defaultDeviceName,
|
||||
UserId = userId,
|
||||
};
|
||||
var deviceRepository = factory.GetService<IDeviceRepository>();
|
||||
await deviceRepository.CreateAsync(userDevice);
|
||||
}
|
||||
|
||||
private async Task AssertStandardError(HttpContext context)
|
||||
{
|
||||
/*
|
||||
An improvement on the current failure flow would be to document which part of
|
||||
the flow failed since all of the failures are basically the same.
|
||||
This doesn't build confidence in the tests.
|
||||
*/
|
||||
|
||||
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("Username or password is incorrect. Try again.", errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
@ -42,6 +44,7 @@ public class BaseRequestValidatorTests
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly IUserDecryptionOptionsBuilder _userDecryptionOptionsBuilder;
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
|
||||
private readonly BaseRequestValidatorTestWrapper _sut;
|
||||
|
||||
@ -62,6 +65,7 @@ public class BaseRequestValidatorTests
|
||||
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
|
||||
_userDecryptionOptionsBuilder = Substitute.For<IUserDecryptionOptionsBuilder>();
|
||||
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
|
||||
_authRequestRepository = Substitute.For<IAuthRequestRepository>();
|
||||
|
||||
_sut = new BaseRequestValidatorTestWrapper(
|
||||
_userManager,
|
||||
@ -78,7 +82,8 @@ public class BaseRequestValidatorTests
|
||||
_featureService,
|
||||
_ssoConfigRepository,
|
||||
_userDecryptionOptionsBuilder,
|
||||
_policyRequirementQuery);
|
||||
_policyRequirementQuery,
|
||||
_authRequestRepository);
|
||||
}
|
||||
|
||||
/* Logic path
|
||||
@ -175,6 +180,99 @@ public class BaseRequestValidatorTests
|
||||
Assert.False(context.GrantResult.IsError);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_ValidatedAuthRequest_ConsumedOnSuccess(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
// 1 -> to pass
|
||||
_sut.isValid = true;
|
||||
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
RequestDeviceIdentifier = "",
|
||||
RequestIpAddress = "1.1.1.1",
|
||||
AccessCode = "password",
|
||||
PublicKey = "test_public_key",
|
||||
CreationDate = DateTime.UtcNow.AddMinutes(-5),
|
||||
ResponseDate = DateTime.UtcNow.AddMinutes(-2),
|
||||
Approved = true,
|
||||
AuthenticationDate = null, // unused
|
||||
UserId = requestContext.User.Id,
|
||||
};
|
||||
requestContext.ValidatedAuthRequest = authRequest;
|
||||
|
||||
// 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);
|
||||
|
||||
// Check that the auth request was consumed
|
||||
await _authRequestRepository.Received(1).ReplaceAsync(Arg.Is<AuthRequest>(ar =>
|
||||
ar.AuthenticationDate.HasValue));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_ValidatedAuthRequest_NotConsumed_When2faRequired(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
// 1 -> to pass
|
||||
_sut.isValid = true;
|
||||
|
||||
var authRequest = new AuthRequest
|
||||
{
|
||||
Type = AuthRequestType.AuthenticateAndUnlock,
|
||||
RequestDeviceIdentifier = "",
|
||||
RequestIpAddress = "1.1.1.1",
|
||||
AccessCode = "password",
|
||||
PublicKey = "test_public_key",
|
||||
CreationDate = DateTime.UtcNow.AddMinutes(-5),
|
||||
ResponseDate = DateTime.UtcNow.AddMinutes(-2),
|
||||
Approved = true,
|
||||
AuthenticationDate = null, // unused
|
||||
UserId = requestContext.User.Id,
|
||||
};
|
||||
requestContext.ValidatedAuthRequest = authRequest;
|
||||
|
||||
// 2 -> will result to false with no extra configuration
|
||||
// 3 -> set two factor to be required
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert we errored for 2fa requirement
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
|
||||
// Assert that the auth request was NOT consumed
|
||||
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any<AuthRequest>());
|
||||
}
|
||||
|
||||
// Test grantTypes that require SSO when a user is in an organization that requires it
|
||||
[Theory]
|
||||
[BitAutoData("password")]
|
||||
|
||||
@ -324,14 +324,26 @@ public class DeviceValidatorTests
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async void ValidateRequestDeviceAsync_IsAuthRequest_SavesDevice_ReturnsTrue(
|
||||
[Theory]
|
||||
[BitAutoData(false, false)]
|
||||
[BitAutoData(true, false)]
|
||||
[BitAutoData(true, true)]
|
||||
[BitAutoData(true, false)]
|
||||
|
||||
public async void ValidateRequestDeviceAsync_IsAuthRequest_UnknownDevice_Errors(
|
||||
bool twoFactoRequired, bool ssoRequired,
|
||||
CustomValidatorRequestContext context,
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
|
||||
{
|
||||
// Arrange
|
||||
context.KnownDevice = false;
|
||||
ArrangeForHandleNewDeviceVerificationTest(context, request);
|
||||
request.GrantType = "password";
|
||||
context.TwoFactorRequired = twoFactoRequired;
|
||||
context.SsoRequired = ssoRequired;
|
||||
if (context.User != null)
|
||||
{
|
||||
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(365);
|
||||
}
|
||||
|
||||
AddValidDeviceToRequest(request);
|
||||
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
|
||||
.Returns(null as Device);
|
||||
@ -342,8 +354,11 @@ public class DeviceValidatorTests
|
||||
var result = await _sut.ValidateRequestDeviceAsync(request, context);
|
||||
|
||||
// Assert
|
||||
await _deviceService.Received(1).SaveAsync(context.Device);
|
||||
Assert.True(result);
|
||||
Assert.False(result);
|
||||
Assert.NotNull(context.CustomResponse["ErrorModel"]);
|
||||
var expectedErrorMessage = "auth request flow unsupported on unknown device";
|
||||
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
|
||||
Assert.Equal(expectedErrorMessage, actualResponse.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
||||
@ -62,7 +62,8 @@ IBaseRequestValidatorTestWrapper
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||
IPolicyRequirementQuery policyRequirementQuery) :
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAuthRequestRepository authRequestRepository) :
|
||||
base(
|
||||
userManager,
|
||||
userService,
|
||||
@ -78,7 +79,8 @@ IBaseRequestValidatorTestWrapper
|
||||
featureService,
|
||||
ssoConfigRepository,
|
||||
userDecryptionOptionsBuilder,
|
||||
policyRequirementQuery)
|
||||
policyRequirementQuery,
|
||||
authRequestRepository)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user