mirror of
https://github.com/bitwarden/server.git
synced 2024-11-25 12:45:18 +01:00
[PM-1815] Include Member Decryption Type in Token Response (#2927)
* Include Member Decryption Type * Make ICurrentContext protected from base class * Return MemberDecryptionType * Extend WebApplicationFactoryBase - Allow for service subsitution * Create SSO Tests - Mock IAuthorizationCodeStore so the SSO process can be limited to Identity * Add MemberDecryptionOptions * Remove Unused Property Assertion * Make MemberDecryptionOptions an Array * Address PR Feedback * Make HasAdminApproval Policy Aware * Format * Use Object Instead * Add UserDecryptionOptions File
This commit is contained in:
parent
ca7ced4e43
commit
5a8e549194
50
src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs
Normal file
50
src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Bit.Core.Models.Api;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Bit.Core.Auth.Models.Api.Response;
|
||||||
|
|
||||||
|
public class UserDecryptionOptions : ResponseModel
|
||||||
|
{
|
||||||
|
public UserDecryptionOptions() : base("userDecryptionOptions")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public bool HasMasterPassword { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public TrustedDeviceUserDecryptionOption? TrustedDeviceOption { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public KeyConnectorUserDecryptionOption? KeyConnectorOption { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TrustedDeviceUserDecryptionOption
|
||||||
|
{
|
||||||
|
public bool HasAdminApproval { get; }
|
||||||
|
|
||||||
|
public TrustedDeviceUserDecryptionOption(bool hasAdminApproval)
|
||||||
|
{
|
||||||
|
HasAdminApproval = hasAdminApproval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KeyConnectorUserDecryptionOption
|
||||||
|
{
|
||||||
|
public string KeyConnectorUrl { get; }
|
||||||
|
|
||||||
|
public KeyConnectorUserDecryptionOption(string keyConnectorUrl)
|
||||||
|
{
|
||||||
|
KeyConnectorUrl = keyConnectorUrl;
|
||||||
|
}
|
||||||
|
}
|
@ -37,12 +37,13 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
private readonly IApplicationCacheService _applicationCacheService;
|
private readonly IApplicationCacheService _applicationCacheService;
|
||||||
private readonly IMailService _mailService;
|
private readonly IMailService _mailService;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly ICurrentContext _currentContext;
|
|
||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly IPolicyService _policyService;
|
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _tokenDataFactory;
|
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _tokenDataFactory;
|
||||||
|
|
||||||
|
protected ICurrentContext CurrentContext { get; }
|
||||||
|
protected IPolicyService PolicyService { get; }
|
||||||
|
|
||||||
public BaseRequestValidator(
|
public BaseRequestValidator(
|
||||||
UserManager<User> userManager,
|
UserManager<User> userManager,
|
||||||
IDeviceRepository deviceRepository,
|
IDeviceRepository deviceRepository,
|
||||||
@ -73,11 +74,10 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
_applicationCacheService = applicationCacheService;
|
_applicationCacheService = applicationCacheService;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_currentContext = currentContext;
|
CurrentContext = currentContext;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_policyService = policyService;
|
PolicyService = policyService;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_policyService = policyService;
|
|
||||||
_tokenDataFactory = tokenDataFactory;
|
_tokenDataFactory = tokenDataFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,7 +284,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
{
|
{
|
||||||
_logger.LogWarning(Constants.BypassFiltersEventId,
|
_logger.LogWarning(Constants.BypassFiltersEventId,
|
||||||
string.Format("Failed login attempt{0}{1}", twoFactorRequest ? ", 2FA invalid." : ".",
|
string.Format("Failed login attempt{0}{1}", twoFactorRequest ? ", 2FA invalid." : ".",
|
||||||
$" {_currentContext.IpAddress}"));
|
$" {CurrentContext.IpAddress}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(2000); // Delay for brute force.
|
await Task.Delay(2000); // Delay for brute force.
|
||||||
@ -314,7 +314,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
(await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0;
|
(await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0;
|
||||||
|
|
||||||
Organization firstEnabledOrg = null;
|
Organization firstEnabledOrg = null;
|
||||||
var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
|
var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (orgs.Any())
|
if (orgs.Any())
|
||||||
{
|
{
|
||||||
@ -341,7 +341,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user belongs to any organization with an active SSO policy
|
// Check if user belongs to any organization with an active SSO policy
|
||||||
var anySsoPoliciesApplicableToUser = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||||
if (anySsoPoliciesApplicableToUser)
|
if (anySsoPoliciesApplicableToUser)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@ -501,7 +501,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
if (!_globalSettings.DisableEmailNewDevice)
|
if (!_globalSettings.DisableEmailNewDevice)
|
||||||
{
|
{
|
||||||
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
|
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
|
||||||
_currentContext.IpAddress);
|
CurrentContext.IpAddress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -543,11 +543,11 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
{
|
{
|
||||||
if (twoFactorInvalid)
|
if (twoFactorInvalid)
|
||||||
{
|
{
|
||||||
await _mailService.SendFailedTwoFactorAttemptsEmailAsync(user.Email, utcNow, _currentContext.IpAddress);
|
await _mailService.SendFailedTwoFactorAttemptsEmailAsync(user.Email, utcNow, CurrentContext.IpAddress);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await _mailService.SendFailedLoginAttemptsEmailAsync(user.Email, utcNow, _currentContext.IpAddress);
|
await _mailService.SendFailedLoginAttemptsEmailAsync(user.Email, utcNow, CurrentContext.IpAddress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -562,7 +562,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
private async Task<MasterPasswordPolicyResponseModel> GetMasterPasswordPolicy(User user)
|
private async Task<MasterPasswordPolicyResponseModel> GetMasterPasswordPolicy(User user)
|
||||||
{
|
{
|
||||||
// Check current context/cache to see if user is in any organizations, avoids extra DB call if not
|
// Check current context/cache to see if user is in any organizations, avoids extra DB call if not
|
||||||
var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
|
var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (!orgs.Any())
|
if (!orgs.Any())
|
||||||
@ -570,6 +570,6 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new MasterPasswordPolicyResponseModel(await _policyService.GetMasterPasswordPolicyForUserAsync(user));
|
return new MasterPasswordPolicyResponseModel(await PolicyService.GetMasterPasswordPolicyForUserAsync(user));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Identity;
|
using Bit.Core.Auth.Identity;
|
||||||
|
using Bit.Core.Auth.Models.Api.Response;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@ -15,13 +19,16 @@ using IdentityServer4.Extensions;
|
|||||||
using IdentityServer4.Validation;
|
using IdentityServer4.Validation;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
namespace Bit.Identity.IdentityServer;
|
namespace Bit.Identity.IdentityServer;
|
||||||
|
|
||||||
public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>,
|
public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>,
|
||||||
ICustomTokenRequestValidator
|
ICustomTokenRequestValidator
|
||||||
{
|
{
|
||||||
private UserManager<User> _userManager;
|
private readonly UserManager<User> _userManager;
|
||||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public CustomTokenRequestValidator(
|
public CustomTokenRequestValidator(
|
||||||
UserManager<User> userManager,
|
UserManager<User> userManager,
|
||||||
@ -41,7 +48,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
|||||||
ISsoConfigRepository ssoConfigRepository,
|
ISsoConfigRepository ssoConfigRepository,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IPolicyService policyService,
|
IPolicyService policyService,
|
||||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory)
|
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
|
||||||
|
IFeatureService featureService)
|
||||||
: base(userManager, deviceRepository, deviceService, userService, eventService,
|
: base(userManager, deviceRepository, deviceService, userService, eventService,
|
||||||
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
|
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
|
||||||
applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository,
|
applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository,
|
||||||
@ -49,6 +57,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
|||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_ssoConfigRepository = ssoConfigRepository;
|
_ssoConfigRepository = ssoConfigRepository;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ValidateAsync(CustomTokenRequestValidationContext context)
|
public async Task ValidateAsync(CustomTokenRequestValidationContext context)
|
||||||
@ -101,6 +110,20 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attempts to find ssoConfigData for a given validate request subject
|
||||||
|
// this is actually guarenteed to pretty often be null, because more than just sso login requests will come
|
||||||
|
// through here
|
||||||
|
var ssoConfigData = await GetSsoConfigurationDataAsync(context.Result.ValidatedRequest.Subject);
|
||||||
|
|
||||||
|
// You can't put this below the user.MasterPassword != null check because TDE users can still have a MasterPassword
|
||||||
|
// It's worth noting that CurrentContext here will build a user in LaunchDarkly that is anonymous but DOES belong
|
||||||
|
// to an organization. So we will not be able to turn this feature on for only a single user, only for an entire
|
||||||
|
// organization at a time.
|
||||||
|
if (ssoConfigData != null && _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, CurrentContext))
|
||||||
|
{
|
||||||
|
context.Result.CustomResponse["UserDecryptionOptions"] = await CreateUserDecryptionOptionsAsync(ssoConfigData, user);
|
||||||
|
}
|
||||||
|
|
||||||
if (context.Result.CustomResponse == null || user.MasterPassword != null)
|
if (context.Result.CustomResponse == null || user.MasterPassword != null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@ -122,23 +145,34 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SSO login
|
// SSO login
|
||||||
var organizationClaim = context.Result.ValidatedRequest.Subject?.FindFirst(c => c.Type == "organizationId");
|
// This does a double check, that ssoConfigData is not null and that it has the KeyConnector member decryption type
|
||||||
if (organizationClaim?.Value != null)
|
if (ssoConfigData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl))
|
||||||
{
|
{
|
||||||
var organizationId = new Guid(organizationClaim.Value);
|
// TODO: Can be removed in the future
|
||||||
|
context.Result.CustomResponse["KeyConnectorUrl"] = ssoConfigData.KeyConnectorUrl;
|
||||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organizationId);
|
// Prevent clients redirecting to set-password
|
||||||
var ssoConfigData = ssoConfig.GetData();
|
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
||||||
|
|
||||||
if (ssoConfigData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl))
|
|
||||||
{
|
|
||||||
context.Result.CustomResponse["KeyConnectorUrl"] = ssoConfigData.KeyConnectorUrl;
|
|
||||||
// Prevent clients redirecting to set-password
|
|
||||||
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<SsoConfigurationData?> GetSsoConfigurationDataAsync(ClaimsPrincipal? subject)
|
||||||
|
{
|
||||||
|
var organizationClaim = subject?.FindFirstValue("organizationId");
|
||||||
|
|
||||||
|
if (organizationClaim == null || !Guid.TryParse(organizationClaim, out var organizationId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organizationId);
|
||||||
|
if (ssoConfig == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ssoConfig.GetData();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context,
|
protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context,
|
||||||
Dictionary<string, object> customResponse)
|
Dictionary<string, object> customResponse)
|
||||||
{
|
{
|
||||||
@ -164,4 +198,29 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
|||||||
context.Result.IsError = true;
|
context.Result.IsError = true;
|
||||||
context.Result.CustomResponse = customResponse;
|
context.Result.CustomResponse = customResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
|
||||||
|
/// </summary>
|
||||||
|
private async Task<UserDecryptionOptions> CreateUserDecryptionOptionsAsync(SsoConfigurationData ssoConfigurationData, User user)
|
||||||
|
{
|
||||||
|
var userDecryptionOption = new UserDecryptionOptions();
|
||||||
|
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
|
||||||
|
{
|
||||||
|
// KeyConnector makes it mutually exclusive
|
||||||
|
userDecryptionOption.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl);
|
||||||
|
return userDecryptionOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption })
|
||||||
|
{
|
||||||
|
var hasAdminApproval = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.ResetPassword);
|
||||||
|
// TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true
|
||||||
|
userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(hasAdminApproval);
|
||||||
|
}
|
||||||
|
|
||||||
|
userDecryptionOption.HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword);
|
||||||
|
|
||||||
|
return userDecryptionOption;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,391 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.Auth.Entities;
|
||||||
|
using Bit.Core.Auth.Enums;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||||
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.Auth.Repositories;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.IntegrationTestCommon.Factories;
|
||||||
|
using Bit.Test.Common.Helpers;
|
||||||
|
using IdentityModel;
|
||||||
|
using IdentityServer4.Models;
|
||||||
|
using IdentityServer4.Stores;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Bit.Identity.IntegrationTest.Endpoints;
|
||||||
|
|
||||||
|
public class IdentityServerSsoTests
|
||||||
|
{
|
||||||
|
const string TestEmail = "sso_user@email.com";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Test_MasterPassword_DecryptionType()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var challenge = new string('c', 50);
|
||||||
|
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||||
|
{
|
||||||
|
MemberDecryptionType = MemberDecryptionType.MasterPassword,
|
||||||
|
}, challenge);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "scope", "api offline_access" },
|
||||||
|
{ "client_id", "web" },
|
||||||
|
{ "deviceType", "10" },
|
||||||
|
{ "deviceIdentifier", "test_id" },
|
||||||
|
{ "deviceName", "firefox" },
|
||||||
|
{ "twoFactorToken", "TEST"},
|
||||||
|
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||||
|
{ "twoFactorRemember", "0" },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "code", "test_code" },
|
||||||
|
{ "code_verifier", challenge },
|
||||||
|
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// If the organization has a member decryption type of MasterPassword that should be the only option in the reply
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||||
|
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||||
|
var root = responseBody.RootElement;
|
||||||
|
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||||
|
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||||
|
|
||||||
|
// Expected to look like:
|
||||||
|
// "UserDecryptionOptions": {
|
||||||
|
// "Object": "userDecryptionOptions"
|
||||||
|
// "HasMasterPassword": true
|
||||||
|
// }
|
||||||
|
|
||||||
|
AssertHelper.AssertJsonProperty(userDecryptionOptions, "HasMasterPassword", JsonValueKind.True);
|
||||||
|
|
||||||
|
// One property for the Object and one for master password
|
||||||
|
Assert.Equal(2, userDecryptionOptions.EnumerateObject().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SsoLogin_TrustedDeviceEncryption_ReturnsOptions()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var challenge = new string('c', 50);
|
||||||
|
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||||
|
{
|
||||||
|
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
|
||||||
|
}, challenge);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "scope", "api offline_access" },
|
||||||
|
{ "client_id", "web" },
|
||||||
|
{ "deviceType", "10" },
|
||||||
|
{ "deviceIdentifier", "test_id" },
|
||||||
|
{ "deviceName", "firefox" },
|
||||||
|
{ "twoFactorToken", "TEST"},
|
||||||
|
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||||
|
{ "twoFactorRemember", "0" },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "code", "test_code" },
|
||||||
|
{ "code_verifier", challenge },
|
||||||
|
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
|
||||||
|
// they can decrypt with either option
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||||
|
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||||
|
var root = responseBody.RootElement;
|
||||||
|
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||||
|
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||||
|
|
||||||
|
// Expected to look like:
|
||||||
|
// "UserDecryptionOptions": {
|
||||||
|
// "Object": "userDecryptionOptions"
|
||||||
|
// "HasMasterPassword": true,
|
||||||
|
// "TrustedDeviceOption": {
|
||||||
|
// "HasAdminApproval": false
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Should have master password & one for trusted device with admin approval
|
||||||
|
AssertHelper.AssertJsonProperty(userDecryptionOptions, "HasMasterPassword", JsonValueKind.True);
|
||||||
|
|
||||||
|
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
|
||||||
|
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SsoLogin_TrustedDeviceEncryption_WithAdminResetPolicy_ReturnsOptions()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var challenge = new string('c', 50);
|
||||||
|
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||||
|
{
|
||||||
|
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
|
||||||
|
}, challenge);
|
||||||
|
|
||||||
|
var database = factory.GetDatabaseContext();
|
||||||
|
|
||||||
|
var organization = await database.Organizations.SingleAsync();
|
||||||
|
|
||||||
|
var policyRepository = factory.Services.GetRequiredService<IPolicyRepository>();
|
||||||
|
await policyRepository.CreateAsync(new Policy
|
||||||
|
{
|
||||||
|
Type = PolicyType.ResetPassword,
|
||||||
|
Enabled = true,
|
||||||
|
Data = "{\"autoEnrollEnabled\": false }",
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "scope", "api offline_access" },
|
||||||
|
{ "client_id", "web" },
|
||||||
|
{ "deviceType", "10" },
|
||||||
|
{ "deviceIdentifier", "test_id" },
|
||||||
|
{ "deviceName", "firefox" },
|
||||||
|
{ "twoFactorToken", "TEST"},
|
||||||
|
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||||
|
{ "twoFactorRemember", "0" },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "code", "test_code" },
|
||||||
|
{ "code_verifier", challenge },
|
||||||
|
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
|
||||||
|
// they can decrypt with either option
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||||
|
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||||
|
var root = responseBody.RootElement;
|
||||||
|
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||||
|
|
||||||
|
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||||
|
|
||||||
|
// Expected to look like:
|
||||||
|
// "UserDecryptionOptions": {
|
||||||
|
// "Object": "userDecryptionOptions"
|
||||||
|
// "HasMasterPassword": true,
|
||||||
|
// "TrustedDeviceOption": {
|
||||||
|
// "HasAdminApproval": true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Should have one item for master password & one for trusted device with admin approval
|
||||||
|
AssertHelper.AssertJsonProperty(userDecryptionOptions, "HasMasterPassword", JsonValueKind.True);
|
||||||
|
|
||||||
|
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
|
||||||
|
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_ReturnsOneOption()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var challenge = new string('c', 50);
|
||||||
|
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||||
|
{
|
||||||
|
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
|
||||||
|
}, challenge);
|
||||||
|
|
||||||
|
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "scope", "api offline_access" },
|
||||||
|
{ "client_id", "web" },
|
||||||
|
{ "deviceType", "10" },
|
||||||
|
{ "deviceIdentifier", "test_id" },
|
||||||
|
{ "deviceName", "firefox" },
|
||||||
|
{ "twoFactorToken", "TEST"},
|
||||||
|
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||||
|
{ "twoFactorRemember", "0" },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "code", "test_code" },
|
||||||
|
{ "code_verifier", challenge },
|
||||||
|
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
|
||||||
|
// they can decrypt with either option
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||||
|
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||||
|
var root = responseBody.RootElement;
|
||||||
|
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||||
|
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||||
|
|
||||||
|
// Expected to look like:
|
||||||
|
// "UserDecryptionOptions": {
|
||||||
|
// "Object": "userDecryptionOptions"
|
||||||
|
// "HasMasterPassword": false,
|
||||||
|
// "TrustedDeviceOption": {
|
||||||
|
// "HasAdminApproval": true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
|
||||||
|
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SsoLogin_KeyConnector_ReturnsOptions()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var challenge = new string('c', 50);
|
||||||
|
var factory = await CreateFactoryAsync(new SsoConfigurationData
|
||||||
|
{
|
||||||
|
MemberDecryptionType = MemberDecryptionType.KeyConnector,
|
||||||
|
KeyConnectorUrl = "https://key_connector.com"
|
||||||
|
}, challenge);
|
||||||
|
|
||||||
|
await UpdateUserAsync(factory, user => user.MasterPassword = null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "scope", "api offline_access" },
|
||||||
|
{ "client_id", "web" },
|
||||||
|
{ "deviceType", "10" },
|
||||||
|
{ "deviceIdentifier", "test_id" },
|
||||||
|
{ "deviceName", "firefox" },
|
||||||
|
{ "twoFactorToken", "TEST"},
|
||||||
|
{ "twoFactorProvider", "5" }, // RememberMe Provider
|
||||||
|
{ "twoFactorRemember", "0" },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "code", "test_code" },
|
||||||
|
{ "code_verifier", challenge },
|
||||||
|
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||||
|
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||||
|
var root = responseBody.RootElement;
|
||||||
|
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
|
||||||
|
|
||||||
|
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
|
||||||
|
|
||||||
|
// Expected to look like:
|
||||||
|
// "UserDecryptionOptions": {
|
||||||
|
// "Object": "userDecryptionOptions"
|
||||||
|
// "KeyConnectorOption": {
|
||||||
|
// "KeyConnectorUrl": "https://key_connector.com"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
var keyConnectorOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "KeyConnectorOption", JsonValueKind.Object);
|
||||||
|
|
||||||
|
var keyConnectorUrl = AssertHelper.AssertJsonProperty(keyConnectorOption, "KeyConnectorUrl", JsonValueKind.String).GetString();
|
||||||
|
Assert.Equal("https://key_connector.com", keyConnectorUrl);
|
||||||
|
|
||||||
|
// For backwards compatibility reasons the url should also be on the root
|
||||||
|
keyConnectorUrl = AssertHelper.AssertJsonProperty(root, "KeyConnectorUrl", JsonValueKind.String).GetString();
|
||||||
|
Assert.Equal("https://key_connector.com", keyConnectorUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IdentityApplicationFactory> CreateFactoryAsync(SsoConfigurationData ssoConfigurationData, string challenge)
|
||||||
|
{
|
||||||
|
var factory = new IdentityApplicationFactory();
|
||||||
|
|
||||||
|
|
||||||
|
var authorizationCode = new AuthorizationCode
|
||||||
|
{
|
||||||
|
ClientId = "web",
|
||||||
|
CreationTime = DateTime.UtcNow,
|
||||||
|
Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds,
|
||||||
|
RedirectUri = "https://localhost:8080/sso-connector.html",
|
||||||
|
RequestedScopes = new[] { "api", "offline_access" },
|
||||||
|
CodeChallenge = challenge.Sha256(),
|
||||||
|
CodeChallengeMethod = "plain", //
|
||||||
|
Subject = null, // Temporarily set it to null
|
||||||
|
};
|
||||||
|
|
||||||
|
factory.SubstitueService<IAuthorizationCodeStore>(service =>
|
||||||
|
{
|
||||||
|
service.GetAuthorizationCodeAsync("test_code")
|
||||||
|
.Returns(authorizationCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
factory.SubstitueService<IFeatureService>(service =>
|
||||||
|
{
|
||||||
|
service.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, Arg.Any<ICurrentContext>())
|
||||||
|
.Returns(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This starts the server and finalizes services
|
||||||
|
var registerResponse = await factory.RegisterAsync(new RegisterRequestModel
|
||||||
|
{
|
||||||
|
Email = TestEmail,
|
||||||
|
MasterPasswordHash = "master_password_hash",
|
||||||
|
});
|
||||||
|
|
||||||
|
var userRepository = factory.Services.GetRequiredService<IUserRepository>();
|
||||||
|
var user = await userRepository.GetByEmailAsync(TestEmail);
|
||||||
|
|
||||||
|
var organizationRepository = factory.Services.GetRequiredService<IOrganizationRepository>();
|
||||||
|
var organization = await organizationRepository.CreateAsync(new Organization
|
||||||
|
{
|
||||||
|
Name = "Test Org",
|
||||||
|
});
|
||||||
|
|
||||||
|
var organizationUserRepository = factory.Services.GetRequiredService<IOrganizationUserRepository>();
|
||||||
|
var organizationUser = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
|
Type = OrganizationUserType.User,
|
||||||
|
});
|
||||||
|
|
||||||
|
var ssoConfigRepository = factory.Services.GetRequiredService<ISsoConfigRepository>();
|
||||||
|
await ssoConfigRepository.CreateAsync(new SsoConfig
|
||||||
|
{
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Enabled = true,
|
||||||
|
Data = JsonSerializer.Serialize(ssoConfigurationData, JsonHelpers.CamelCase),
|
||||||
|
});
|
||||||
|
|
||||||
|
var subject = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtClaimTypes.Subject, user.Id.ToString()), // Get real user id
|
||||||
|
new Claim(JwtClaimTypes.Name, TestEmail),
|
||||||
|
new Claim(JwtClaimTypes.IdentityProvider, "sso"),
|
||||||
|
new Claim("organizationId", organization.Id.ToString()),
|
||||||
|
new Claim(JwtClaimTypes.SessionId, "SOMETHING"),
|
||||||
|
new Claim(JwtClaimTypes.AuthenticationMethod, "external"),
|
||||||
|
new Claim(JwtClaimTypes.AuthenticationTime, DateTime.UtcNow.AddMinutes(-1).ToEpochTime().ToString())
|
||||||
|
}, "IdentityServer4", JwtClaimTypes.Name, JwtClaimTypes.Role));
|
||||||
|
|
||||||
|
authorizationCode.Subject = subject;
|
||||||
|
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UpdateUserAsync(IdentityApplicationFactory factory, Action<User> changeUser)
|
||||||
|
{
|
||||||
|
var userRepository = factory.Services.GetRequiredService<IUserRepository>();
|
||||||
|
var user = await userRepository.GetByEmailAsync(TestEmail);
|
||||||
|
|
||||||
|
changeUser(user);
|
||||||
|
|
||||||
|
await userRepository.ReplaceAsync(user);
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
using NoopRepos = Bit.Core.Repositories.Noop;
|
using NoopRepos = Bit.Core.Repositories.Noop;
|
||||||
|
|
||||||
namespace Bit.IntegrationTestCommon.Factories;
|
namespace Bit.IntegrationTestCommon.Factories;
|
||||||
@ -33,6 +34,23 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string DatabaseName { get; set; } = Guid.NewGuid().ToString();
|
public string DatabaseName { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
private readonly List<Action<IServiceCollection>> _configureTestServices = new();
|
||||||
|
|
||||||
|
public void SubstitueService<TService>(Action<TService> mockService)
|
||||||
|
where TService : class
|
||||||
|
{
|
||||||
|
_configureTestServices.Add(services =>
|
||||||
|
{
|
||||||
|
var foundServiceDescriptor = services.FirstOrDefault(sd => sd.ServiceType == typeof(TService))
|
||||||
|
?? throw new InvalidOperationException($"Could not find service of type {typeof(TService).FullName} to substitute");
|
||||||
|
services.Remove(foundServiceDescriptor);
|
||||||
|
|
||||||
|
var substitutedService = Substitute.For<TService>();
|
||||||
|
mockService(substitutedService);
|
||||||
|
services.Add(ServiceDescriptor.Singleton(typeof(TService), substitutedService));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configure the web host to use an EF in memory database
|
/// Configure the web host to use an EF in memory database
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -146,6 +164,11 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
|
|||||||
// Disable logs
|
// Disable logs
|
||||||
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
|
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
foreach (var configureTestService in _configureTestServices)
|
||||||
|
{
|
||||||
|
builder.ConfigureTestServices(configureTestService);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public DatabaseContext GetDatabaseContext()
|
public DatabaseContext GetDatabaseContext()
|
||||||
|
Loading…
Reference in New Issue
Block a user