diff --git a/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs b/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs new file mode 100644 index 000000000..bcafc0e89 --- /dev/null +++ b/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Auth.Enums; + +public enum WebAuthnLoginAssertionOptionsScope +{ + Authentication = 0, + PrfRegistration = 1 +} diff --git a/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs new file mode 100644 index 000000000..6a0641246 --- /dev/null +++ b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs @@ -0,0 +1,18 @@ + +using Bit.Core.Models.Api; +using Fido2NetLib; + +namespace Bit.Core.Auth.Models.Api.Response.Accounts; + +public class WebAuthnLoginAssertionOptionsResponseModel : ResponseModel +{ + private const string ResponseObj = "webAuthnLoginAssertionOptions"; + + public WebAuthnLoginAssertionOptionsResponseModel() : base(ResponseObj) + { + } + + public AssertionOptions Options { get; set; } + public string Token { get; set; } +} + diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index edfcce5a5..06990afea 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -16,6 +16,12 @@ public class UserDecryptionOptions : ResponseModel /// public bool HasMasterPassword { get; set; } + /// + /// Gets or sets the WebAuthn PRF decryption keys. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WebAuthnPrfDecryptionOption? WebAuthnPrfOption { get; set; } + /// /// Gets or sets information regarding this users trusted device decryption setup. /// @@ -29,6 +35,20 @@ public class UserDecryptionOptions : ResponseModel public KeyConnectorUserDecryptionOption? KeyConnectorOption { get; set; } } +public class WebAuthnPrfDecryptionOption +{ + public string EncryptedPrivateKey { get; } + public string EncryptedUserKey { get; } + + public WebAuthnPrfDecryptionOption( + string encryptedPrivateKey, + string encryptedUserKey) + { + EncryptedPrivateKey = encryptedPrivateKey; + EncryptedUserKey = encryptedUserKey; + } +} + public class TrustedDeviceUserDecryptionOption { public bool HasAdminApproval { get; } diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs new file mode 100644 index 000000000..017033b00 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using Bit.Core.Auth.Enums; +using Bit.Core.Tokens; +using Fido2NetLib; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +public class WebAuthnLoginAssertionOptionsTokenable : ExpiringTokenable +{ + // Lifetime 17 minutes = + // - 6 Minutes for Attestation (max webauthn timeout) + // - 6 Minutes for PRF Assertion (max webauthn timeout) + // - 5 minutes for user to complete the process (name their passkey, etc) + private static readonly TimeSpan _tokenLifetime = TimeSpan.FromMinutes(17); + public const string ClearTextPrefix = "BWWebAuthnLoginAssertionOptions_"; + public const string DataProtectorPurpose = "WebAuthnLoginAssertionOptionsDataProtector"; + public const string TokenIdentifier = "WebAuthnLoginAssertionOptionsToken"; + + public string Identifier { get; set; } = TokenIdentifier; + public AssertionOptions Options { get; set; } + public WebAuthnLoginAssertionOptionsScope Scope { get; set; } + + [JsonConstructor] + public WebAuthnLoginAssertionOptionsTokenable() + { + ExpirationDate = DateTime.UtcNow.Add(_tokenLifetime); + } + + public WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions options) : this() + { + Scope = scope; + Options = options; + } + + public bool TokenIsValid(WebAuthnLoginAssertionOptionsScope scope) + { + if (!Valid) + { + return false; + } + + return Scope == scope; + } + + protected override bool TokenIsValid() => Identifier == TokenIdentifier && Options != null; +} + diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs deleted file mode 100644 index b27b1fb35..000000000 --- a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text.Json.Serialization; -using Bit.Core.Entities; -using Bit.Core.Tokens; - -namespace Bit.Core.Auth.Models.Business.Tokenables; - -public class WebAuthnLoginTokenable : ExpiringTokenable -{ - private const double _tokenLifetimeInHours = (double)1 / 60; // 1 minute - public const string ClearTextPrefix = "BWWebAuthnLogin_"; - public const string DataProtectorPurpose = "WebAuthnLoginDataProtector"; - public const string TokenIdentifier = "WebAuthnLoginToken"; - - public string Identifier { get; set; } = TokenIdentifier; - public Guid Id { get; set; } - public string Email { get; set; } - - [JsonConstructor] - public WebAuthnLoginTokenable() - { - ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours); - } - - public WebAuthnLoginTokenable(User user) : this() - { - Id = user?.Id ?? default; - Email = user?.Email; - } - - public bool TokenIsValid(User user) - { - if (Id == default || Email == default || user == null) - { - return false; - } - - return Id == user.Id && - Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase); - } - - // Validates deserialized - protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email); -} diff --git a/src/Core/Auth/Utilities/GuidUtilities.cs b/src/Core/Auth/Utilities/GuidUtilities.cs new file mode 100644 index 000000000..043e326b1 --- /dev/null +++ b/src/Core/Auth/Utilities/GuidUtilities.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Auth.Utilities; + +public static class GuidUtilities +{ + public static bool TryParseBytes(ReadOnlySpan bytes, out Guid guid) + { + try + { + guid = new Guid(bytes); + return true; + } + catch + { + guid = Guid.Empty; + return false; + } + } +} + diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 208b43116..aea0b56fe 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -38,6 +38,11 @@ public static class Constants /// regardless of whether there is a proration or not. /// public const string AlwaysInvoice = "always_invoice"; + + /// + /// Used by IdentityServer to identify our own provider. + /// + public const string IdentityProvider = "bitwarden"; } public static class TokenPurposes diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 14401548b..c9ccef661 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; @@ -29,8 +30,8 @@ public interface IUserService Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse); Task StartWebAuthnLoginRegistrationAsync(User user); Task CompleteWebAuthLoginRegistrationAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null); - Task StartWebAuthnLoginAssertionAsync(User user); - Task CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user); + AssertionOptions StartWebAuthnLoginAssertion(); + Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse); Task SendEmailVerificationAsync(User user); Task ConfirmEmailAsync(User user, string token); Task InitiateEmailChangeAsync(User user, string newEmail); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 95ad5ac4c..1d7f95f1f 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -6,6 +6,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -61,9 +62,8 @@ public class UserService : UserManager, IUserService, IDisposable private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IProviderUserRepository _providerUserRepository; private readonly IStripeSyncService _stripeSyncService; - private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; - private readonly IDataProtectorTokenFactory _webAuthnLoginTokenizer; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; public UserService( IUserRepository userRepository, @@ -96,8 +96,7 @@ public class UserService : UserManager, IUserService, IDisposable IProviderUserRepository providerUserRepository, IStripeSyncService stripeSyncService, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IWebAuthnCredentialRepository webAuthnRepository, - IDataProtectorTokenFactory webAuthnLoginTokenizer) + IWebAuthnCredentialRepository webAuthnRepository) : base( store, optionsAccessor, @@ -136,7 +135,6 @@ public class UserService : UserManager, IUserService, IDisposable _stripeSyncService = stripeSyncService; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _webAuthnCredentialRepository = webAuthnRepository; - _webAuthnLoginTokenizer = webAuthnLoginTokenizer; } public Guid? GetProperUserId(ClaimsPrincipal principal) @@ -586,45 +584,33 @@ public class UserService : UserManager, IUserService, IDisposable return true; } - public async Task StartWebAuthnLoginAssertionAsync(User user) + public AssertionOptions StartWebAuthnLoginAssertion() { - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); - var existingKeys = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); - var existingCredentials = existingKeys - .Select(k => new PublicKeyCredentialDescriptor(CoreHelpers.Base64UrlDecode(k.CredentialId))) - .ToList(); - - if (existingCredentials.Count == 0) - { - return null; - } - - // TODO: PRF? - var exts = new AuthenticationExtensionsClientInputs - { - UserVerificationMethod = true - }; - var options = _fido2.GetAssertionOptions(existingCredentials, UserVerificationRequirement.Required, exts); - - // TODO: temp save options to user record somehow - - return options; + return _fido2.GetAssertionOptions(Enumerable.Empty(), UserVerificationRequirement.Required); } - public async Task CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user) + public async Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse) { - // TODO: Get options from user record somehow, then clear them - var options = AssertionOptions.FromJson(""); - - var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); - var assertionId = CoreHelpers.Base64UrlEncode(assertionResponse.Id); - var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertionId); - if (credential == null) + if (!GuidUtilities.TryParseBytes(assertionResponse.Response.UserHandle, out var userId)) { - return null; + throw new BadRequestException("Invalid credential."); } - // TODO: Callback to ensure credential ID is unique. Do we care? I don't think so. + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + throw new BadRequestException("Invalid credential."); + } + + var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); + var assertedCredentialId = CoreHelpers.Base64UrlEncode(assertionResponse.Id); + var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertedCredentialId); + if (credential == null) + { + throw new BadRequestException("Invalid credential."); + } + + // Always return true, since we've already filtered the credentials after user id IsUserHandleOwnerOfCredentialIdAsync callback = (args, cancellationToken) => Task.FromResult(true); var credentialPublicKey = CoreHelpers.Base64UrlDecode(credential.PublicKey); var assertionVerificationResult = await _fido2.MakeAssertionAsync( @@ -634,15 +620,12 @@ public class UserService : UserManager, IUserService, IDisposable credential.Counter = (int)assertionVerificationResult.Counter; await _webAuthnCredentialRepository.ReplaceAsync(credential); - if (assertionVerificationResult.Status == "ok") + if (assertionVerificationResult.Status != "ok") { - var token = _webAuthnLoginTokenizer.Protect(new WebAuthnLoginTokenable(user)); - return token; - } - else - { - return null; + throw new BadRequestException("Invalid credential."); } + + return (user, credential); } public async Task SendEmailVerificationAsync(User user) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 9073884d8..a686afa53 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,6 +1,8 @@ using Bit.Core; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Auth.Utilities; using Bit.Core.Enums; @@ -8,9 +10,9 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; -using Fido2NetLib; using Microsoft.AspNetCore.Mvc; namespace Bit.Identity.Controllers; @@ -23,17 +25,21 @@ public class AccountsController : Controller private readonly IUserRepository _userRepository; private readonly IUserService _userService; private readonly ICaptchaValidationService _captchaValidationService; + private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; + public AccountsController( ILogger logger, IUserRepository userRepository, IUserService userService, - ICaptchaValidationService captchaValidationService) + ICaptchaValidationService captchaValidationService, + IDataProtectorTokenFactory assertionOptionsDataProtector) { _logger = logger; _userRepository = userRepository; _userService = userService; _captchaValidationService = captchaValidationService; + _assertionOptionsDataProtector = assertionOptionsDataProtector; } // Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints. @@ -75,36 +81,19 @@ public class AccountsController : Controller return new PreloginResponseModel(kdfInformation); } - [HttpPost("webauthn-assertion-options")] - [ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly + [HttpPost("webauthn/assertion-options")] [RequireFeature(FeatureFlagKeys.PasswordlessLogin)] - // TODO: Create proper models for this call - public async Task PostWebAuthnAssertionOptions([FromBody] PreloginRequestModel model) + public WebAuthnLoginAssertionOptionsResponseModel PostWebAuthnLoginAssertionOptions() { - var user = await _userRepository.GetByEmailAsync(model.Email); - if (user == null) + var options = _userService.StartWebAuthnLoginAssertion(); + + var tokenable = new WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope.Authentication, options); + var token = _assertionOptionsDataProtector.Protect(tokenable); + + return new WebAuthnLoginAssertionOptionsResponseModel { - // TODO: return something? possible enumeration attacks with this response - return new AssertionOptions(); - } - - var options = await _userService.StartWebAuthnLoginAssertionAsync(user); - return options; - } - - [HttpPost("webauthn-assertion")] - [RequireFeature(FeatureFlagKeys.PasswordlessLogin)] - // TODO: Create proper models for this call - public async Task PostWebAuthnAssertion([FromBody] PreloginRequestModel model) - { - var user = await _userRepository.GetByEmailAsync(model.Email); - if (user == null) - { - // TODO: proper response here? - throw new BadRequestException(); - } - - var token = await _userService.CompleteWebAuthLoginAssertionAsync(null, user); - return token; + Options = options, + Token = token + }; } } diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index 8d2a294be..7457a8d0e 100644 --- a/src/Identity/IdentityServer/ApiClient.cs +++ b/src/Identity/IdentityServer/ApiClient.cs @@ -13,7 +13,7 @@ public class ApiClient : Client string[] scopes = null) { ClientId = id; - AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode }; + AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType }; RefreshTokenExpiration = TokenExpiration.Sliding; RefreshTokenUsage = TokenUsage.ReUse; SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays; diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index d52d3064a..c01dc2230 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -10,7 +10,6 @@ using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -23,7 +22,6 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Utilities; -using Bit.Identity.Utilities; using IdentityServer4.Validation; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; @@ -35,7 +33,6 @@ public abstract class BaseRequestValidator where T : class private UserManager _userManager; private readonly IDeviceRepository _deviceRepository; private readonly IDeviceService _deviceService; - private readonly IUserService _userService; private readonly IEventService _eventService; private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; private readonly IOrganizationRepository _organizationRepository; @@ -53,6 +50,8 @@ public abstract class BaseRequestValidator where T : class protected IPolicyService PolicyService { get; } protected IFeatureService FeatureService { get; } protected ISsoConfigRepository SsoConfigRepository { get; } + protected IUserService _userService { get; } + protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; } public BaseRequestValidator( UserManager userManager, @@ -73,7 +72,8 @@ public abstract class BaseRequestValidator where T : class IDataProtectorTokenFactory tokenDataFactory, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, - IDistributedCache distributedCache) + IDistributedCache distributedCache, + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) { _userManager = userManager; _deviceRepository = deviceRepository; @@ -96,11 +96,12 @@ public abstract class BaseRequestValidator where T : class _distributedCache = distributedCache; _cacheEntryOptions = new DistributedCacheEntryOptions { - // This sets the time an item is cached to 15 minutes. This value is hard coded - // to 15 because to it covers all time-out windows for both Authenticators and + // This sets the time an item is cached to 17 minutes. This value is hard coded + // to 17 because to it covers all time-out windows for both Authenticators and // Email TOTP. - AbsoluteExpirationRelativeToNow = new TimeSpan(0, 15, 0) + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(17) }; + UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -333,7 +334,7 @@ public abstract class BaseRequestValidator where T : class protected abstract void SetErrorResult(T context, Dictionary customResponse); protected abstract ClaimsPrincipal GetSubject(T context); - private async Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request) + protected virtual async Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request) { if (request.GrantType == "client_credentials") { @@ -612,67 +613,12 @@ public abstract class BaseRequestValidator where T : class /// private async Task CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject) { - var ssoConfiguration = await GetSsoConfigurationDataAsync(subject); - - var userDecryptionOption = new UserDecryptionOptions - { - HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword) - }; - - var ssoConfigurationData = ssoConfiguration?.GetData(); - - if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) - { - // KeyConnector makes it mutually exclusive - userDecryptionOption.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); - return userDecryptionOption; - } - - // Only add the trusted device specific option when the flag is turned on - if (FeatureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, CurrentContext) && ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }) - { - string? encryptedPrivateKey = null; - string? encryptedUserKey = null; - if (device.IsTrusted()) - { - encryptedPrivateKey = device.EncryptedPrivateKey; - encryptedUserKey = device.EncryptedUserKey; - } - - var allDevices = await _deviceRepository.GetManyByUserIdAsync(user.Id); - // Checks if the current user has any devices that are capable of approving login with device requests except for - // their current device. - // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting. - var hasLoginApprovingDevice = allDevices - .Where(d => d.Identifier != device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type)) - .Any(); - - // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP - var hasManageResetPasswordPermission = false; - - // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here - if (CurrentContext.Organizations.Any(o => o.Id == ssoConfiguration!.OrganizationId)) - { - // TDE requires single org so grabbing first org & id is fine. - hasManageResetPasswordPermission = await CurrentContext.ManageResetPassword(ssoConfiguration!.OrganizationId); - } - - // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(ssoConfiguration!.OrganizationId, user.Id); - - // They are only able to be approved by an admin if they have enrolled is reset password - var hasAdminApproval = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); - - // TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true - userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption( - hasAdminApproval, - hasLoginApprovingDevice, - hasManageResetPasswordPermission, - encryptedPrivateKey, - encryptedUserKey); - } - - return userDecryptionOption; + var ssoConfig = await GetSsoConfigurationDataAsync(subject); + return await UserDecryptionOptionsBuilder + .ForUser(user) + .WithDevice(device) + .WithSso(ssoConfig) + .BuildAsync(); } private async Task GetSsoConfigurationDataAsync(ClaimsPrincipal subject) diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs index a15a73837..00f563154 100644 --- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs @@ -44,12 +44,13 @@ public class CustomTokenRequestValidator : BaseRequestValidator tokenDataFactory, IFeatureService featureService, - IDistributedCache distributedCache) + IDistributedCache distributedCache, + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, - distributedCache) + distributedCache, userDecryptionOptionsBuilder) { _userManager = userManager; } diff --git a/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs new file mode 100644 index 000000000..dad9d8e27 --- /dev/null +++ b/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs @@ -0,0 +1,13 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Api.Response; +using Bit.Core.Entities; + +namespace Bit.Identity.IdentityServer; +public interface IUserDecryptionOptionsBuilder +{ + IUserDecryptionOptionsBuilder ForUser(User user); + IUserDecryptionOptionsBuilder WithDevice(Device device); + IUserDecryptionOptionsBuilder WithSso(SsoConfig ssoConfig); + IUserDecryptionOptionsBuilder WithWebAuthnLoginCredential(WebAuthnCredential credential); + Task BuildAsync(); +} diff --git a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs index 8b8b0be52..52e39e64a 100644 --- a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Bit.Core; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; @@ -46,11 +47,12 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator tokenDataFactory, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, - IDistributedCache distributedCache) + IDistributedCache distributedCache, + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, - tokenDataFactory, featureService, ssoConfigRepository, distributedCache) + tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder) { _userManager = userManager; _userService = userService; @@ -144,7 +146,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator claims, Dictionary customResponse) { context.Result = new GrantValidationResult(user.Id.ToString(), "Application", - identityProvider: "bitwarden", + identityProvider: Constants.IdentityProvider, claims: claims.Count > 0 ? claims : null, customResponse: customResponse); return Task.CompletedTask; diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs new file mode 100644 index 000000000..4366ddbda --- /dev/null +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -0,0 +1,155 @@ +using Bit.Core; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Response; +using Bit.Core.Auth.Utilities; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Identity.Utilities; + +namespace Bit.Identity.IdentityServer; + +#nullable enable +/// +/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents +/// +/// Note: Do not use this as an injected service if you intend to build multiple independent UserDecryptionOptions +/// +public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder +{ + private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; + private readonly IDeviceRepository _deviceRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + + private UserDecryptionOptions _options = new UserDecryptionOptions(); + private User? _user; + private Core.Auth.Entities.SsoConfig? _ssoConfig; + private Device? _device; + + public UserDecryptionOptionsBuilder( + ICurrentContext currentContext, + IFeatureService featureService, + IDeviceRepository deviceRepository, + IOrganizationUserRepository organizationUserRepository + ) + { + _currentContext = currentContext; + _featureService = featureService; + _deviceRepository = deviceRepository; + _organizationUserRepository = organizationUserRepository; + } + + public IUserDecryptionOptionsBuilder ForUser(User user) + { + _options.HasMasterPassword = user.HasMasterPassword(); + _user = user; + return this; + } + + public IUserDecryptionOptionsBuilder WithSso(Core.Auth.Entities.SsoConfig ssoConfig) + { + _ssoConfig = ssoConfig; + return this; + } + + public IUserDecryptionOptionsBuilder WithDevice(Device device) + { + _device = device; + return this; + } + + public IUserDecryptionOptionsBuilder WithWebAuthnLoginCredential(WebAuthnCredential credential) + { + if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled) + { + _options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); + } + return this; + } + + public async Task BuildAsync() + { + BuildKeyConnectorOptions(); + await BuildTrustedDeviceOptions(); + + return _options; + } + + private void BuildKeyConnectorOptions() + { + if (_ssoConfig == null) + { + return; + } + + var ssoConfigurationData = _ssoConfig.GetData(); + if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) + { + _options.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); + } + } + + private async Task BuildTrustedDeviceOptions() + { + // TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change + if (_ssoConfig == null || !_featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext)) + { + return; + } + + var ssoConfigurationData = _ssoConfig.GetData(); + if (ssoConfigurationData is not { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }) + { + return; + } + + string? encryptedPrivateKey = null; + string? encryptedUserKey = null; + if (_device != null && _device.IsTrusted()) + { + encryptedPrivateKey = _device.EncryptedPrivateKey; + encryptedUserKey = _device.EncryptedUserKey; + } + + var hasLoginApprovingDevice = false; + if (_device != null && _user != null) + { + var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id); + // Checks if the current user has any devices that are capable of approving login with device requests except for + // their current device. + // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting. + hasLoginApprovingDevice = allDevices + .Where(d => d.Identifier != _device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type)) + .Any(); + } + + // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP + var hasManageResetPasswordPermission = false; + // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here + if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId)) + { + // TDE requires single org so grabbing first org & id is fine. + hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); + } + + var hasAdminApproval = false; + if (_user != null) + { + // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); + + // They are only able to be approved by an admin if they have enrolled is reset password + hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); + } + + _options.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption( + hasAdminApproval, + hasLoginApprovingDevice, + hasManageResetPasswordPermission, + encryptedPrivateKey, + encryptedUserKey); + } +} diff --git a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs new file mode 100644 index 000000000..8d3761d6c --- /dev/null +++ b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs @@ -0,0 +1,150 @@ +using System.Security.Claims; +using System.Text.Json; +using Bit.Core; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; +using Fido2NetLib; +using IdentityServer4.Models; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Caching.Distributed; + +namespace Bit.Identity.IdentityServer; + +public class WebAuthnGrantValidator : BaseRequestValidator, IExtensionGrantValidator +{ + public const string GrantType = "webauthn"; + + private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; + + public WebAuthnGrantValidator( + UserManager userManager, + IDeviceRepository deviceRepository, + IDeviceService deviceService, + IUserService userService, + IEventService eventService, + IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IApplicationCacheService applicationCacheService, + IMailService mailService, + ILogger logger, + ICurrentContext currentContext, + GlobalSettings globalSettings, + ISsoConfigRepository ssoConfigRepository, + IUserRepository userRepository, + IPolicyService policyService, + IDataProtectorTokenFactory tokenDataFactory, + IDataProtectorTokenFactory assertionOptionsDataProtector, + IFeatureService featureService, + IDistributedCache distributedCache, + IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder + ) + : base(userManager, deviceRepository, deviceService, userService, eventService, + organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, + applicationCacheService, mailService, logger, currentContext, globalSettings, + userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder) + { + _assertionOptionsDataProtector = assertionOptionsDataProtector; + } + + string IExtensionGrantValidator.GrantType => "webauthn"; + + public async Task ValidateAsync(ExtensionGrantValidationContext context) + { + if (!FeatureService.IsEnabled(FeatureFlagKeys.PasswordlessLogin, CurrentContext)) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); + return; + } + + var rawToken = context.Request.Raw.Get("token"); + var rawDeviceResponse = context.Request.Raw.Get("deviceResponse"); + if (string.IsNullOrWhiteSpace(rawToken) || string.IsNullOrWhiteSpace(rawDeviceResponse)) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); + return; + } + + var verified = _assertionOptionsDataProtector.TryUnprotect(rawToken, out var token) && + token.TokenIsValid(WebAuthnLoginAssertionOptionsScope.Authentication); + var deviceResponse = JsonSerializer.Deserialize(rawDeviceResponse); + + if (!verified) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest); + return; + } + + var (user, credential) = await _userService.CompleteWebAuthLoginAssertionAsync(token.Options, deviceResponse); + var validatorContext = new CustomValidatorRequestContext + { + User = user, + KnownDevice = await KnownDeviceAsync(user, context.Request) + }; + + UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential); + + await ValidateAsync(context, context.Request, validatorContext); + } + + protected override Task ValidateContextAsync(ExtensionGrantValidationContext context, + CustomValidatorRequestContext validatorContext) + { + if (validatorContext.User == null) + { + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + protected override Task SetSuccessResult(ExtensionGrantValidationContext context, User user, + List claims, Dictionary customResponse) + { + context.Result = new GrantValidationResult(user.Id.ToString(), "Application", + identityProvider: Constants.IdentityProvider, + claims: claims.Count > 0 ? claims : null, + customResponse: customResponse); + return Task.CompletedTask; + } + + protected override ClaimsPrincipal GetSubject(ExtensionGrantValidationContext context) + { + return context.Result.Subject; + } + + protected override Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request) + { + // We consider Fido2 userVerification a second factor, so we don't require a second factor here. + return Task.FromResult(new Tuple(false, null)); + } + + protected override void SetTwoFactorResult(ExtensionGrantValidationContext context, + Dictionary customResponse) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.", + customResponse); + } + + protected override void SetSsoResult(ExtensionGrantValidationContext context, + Dictionary customResponse) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.", + customResponse); + } + + protected override void SetErrorResult(ExtensionGrantValidationContext context, + Dictionary customResponse) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse); + } +} diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 068e12bd4..53b9c49e9 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddTransient(); + services.AddTransient(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services @@ -44,7 +45,8 @@ public static class ServiceCollectionExtensions .AddResourceOwnerValidator() .AddPersistedGrantStore() .AddClientStore() - .AddIdentityServerCertificate(env, globalSettings); + .AddIdentityServerCertificate(env, globalSettings) + .AddExtensionGrantValidator(); services.AddTransient(); return identityServerBuilder; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index c12c849e3..2a2a06efe 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -168,18 +168,18 @@ public static class ServiceCollectionExtensions SsoTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); - services.AddSingleton>(serviceProvider => - new DataProtectorTokenFactory( - WebAuthnLoginTokenable.ClearTextPrefix, - WebAuthnLoginTokenable.DataProtectorPurpose, - serviceProvider.GetDataProtectionProvider(), - serviceProvider.GetRequiredService>>())); services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( WebAuthnCredentialCreateOptionsTokenable.ClearTextPrefix, WebAuthnCredentialCreateOptionsTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + WebAuthnLoginAssertionOptionsTokenable.ClearTextPrefix, + WebAuthnLoginAssertionOptionsTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( SsoEmail2faSessionTokenable.ClearTextPrefix, diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs new file mode 100644 index 000000000..3978fef0f --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs @@ -0,0 +1,61 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Test.Common.AutoFixture.Attributes; +using Fido2NetLib; +using Xunit; + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; + +public class WebAuthnLoginAssertionOptionsTokenableTests +{ + [Theory, BitAutoData] + public void Valid_TokenWithoutOptions_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope, null); + + var isValid = token.Valid; + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void Valid_NewlyCreatedToken_ReturnsTrue(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions createOptions) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope, createOptions); + + + var isValid = token.Valid; + + Assert.True(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_TokenWithoutOptions_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope, null); + + var isValid = token.TokenIsValid(scope); + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_NonMatchingScope_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope1, WebAuthnLoginAssertionOptionsScope scope2, AssertionOptions createOptions) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope1, createOptions); + + var isValid = token.TokenIsValid(scope2); + + Assert.False(isValid); + } + + [Theory, BitAutoData] + public void ValidIsValid_SameScope_ReturnsTrue(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions createOptions) + { + var token = new WebAuthnLoginAssertionOptionsTokenable(scope, createOptions); + + var isValid = token.TokenIsValid(scope); + + Assert.True(isValid); + } +} diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 724532f3c..c3bd1b283 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Text; +using System.Text.Json; using AutoFixture; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; @@ -8,26 +9,29 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tokens; using Bit.Core.Tools.Services; +using Bit.Core.Utilities; using Bit.Core.Vault.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Fakes; using Bit.Test.Common.Helpers; using Fido2NetLib; +using Fido2NetLib.Objects; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ReceivedExtensions; +using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Core.Test.Services; @@ -188,7 +192,7 @@ public class UserServiceTests } [Theory, BitAutoData] - public async void CompleteWebAuthLoginRegistrationAsync_ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator credentialGenerator) + public async Task CompleteWebAuthLoginRegistrationAsync_ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator credentialGenerator) { // Arrange var existingCredentials = credentialGenerator.Take(5).ToList(); @@ -202,6 +206,92 @@ public class UserServiceTests sutProvider.GetDependency().DidNotReceive(); } + [Theory, BitAutoData] + public async Task CompleteWebAuthLoginAssertionAsync_InvalidUserHandle_ThrowsBadRequestException(SutProvider sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response) + { + // Arrange + response.Response.UserHandle = Encoding.UTF8.GetBytes("invalid-user-handle"); + + // Act + var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task CompleteWebAuthLoginAssertionAsync_UserNotFound_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response) + { + // Arrange + response.Response.UserHandle = user.Id.ToByteArray(); + sutProvider.GetDependency().GetByIdAsync(user.Id).ReturnsNull(); + + // Act + var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task CompleteWebAuthLoginAssertionAsync_NoMatchingCredentialExists_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response) + { + // Arrange + response.Response.UserHandle = user.Id.ToByteArray(); + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { }); + + // Act + var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task CompleteWebAuthLoginAssertionAsync_AssertionFails_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult) + { + // Arrange + var credentialId = Guid.NewGuid().ToByteArray(); + credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId); + response.Id = credentialId; + response.Response.UserHandle = user.Id.ToByteArray(); + assertionResult.Status = "Not ok"; + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential }); + sutProvider.GetDependency().MakeAssertionAsync(response, options, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(assertionResult); + + // Act + var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task CompleteWebAuthLoginAssertionAsync_AssertionSucceeds_ReturnsUserAndCredential(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult) + { + // Arrange + var credentialId = Guid.NewGuid().ToByteArray(); + credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId); + response.Id = credentialId; + response.Response.UserHandle = user.Id.ToByteArray(); + assertionResult.Status = "ok"; + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential }); + sutProvider.GetDependency().MakeAssertionAsync(response, options, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(assertionResult); + + // Act + var result = await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response); + + // Assert + var (userResult, credentialResult) = result; + Assert.Equal(user, userResult); + Assert.Equal(credential, credentialResult); + } + [Flags] public enum ShouldCheck { @@ -278,8 +368,7 @@ public class UserServiceTests sutProvider.GetDependency(), sutProvider.GetDependency(), new FakeDataProtectorTokenFactory(), - sutProvider.GetDependency(), - sutProvider.GetDependency>() + sutProvider.GetDependency() ); var actualIsVerified = await sut.VerifySecretAsync(user, secret); diff --git a/test/Identity.IntegrationTest/openid-configuration.json b/test/Identity.IntegrationTest/openid-configuration.json index 9442330da..97e8105f4 100644 --- a/test/Identity.IntegrationTest/openid-configuration.json +++ b/test/Identity.IntegrationTest/openid-configuration.json @@ -38,7 +38,8 @@ "refresh_token", "implicit", "password", - "urn:ietf:params:oauth:grant-type:device_code" + "urn:ietf:params:oauth:grant-type:device_code", + "webauthn" ], "response_types_supported": [ "code", @@ -49,24 +50,13 @@ "code token", "code id_token token" ], - "response_modes_supported": [ - "form_post", - "query", - "fragment" - ], + "response_modes_supported": ["form_post", "query", "fragment"], "token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post" ], - "id_token_signing_alg_values_supported": [ - "RS256" - ], - "subject_types_supported": [ - "public" - ], - "code_challenge_methods_supported": [ - "plain", - "S256" - ], + "id_token_signing_alg_values_supported": ["RS256"], + "subject_types_supported": ["public"], + "code_challenge_methods_supported": ["plain", "S256"], "request_parameter_supported": true } diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index 32473593d..3d94fd54b 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -6,6 +7,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.Identity.Controllers; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; @@ -22,6 +24,7 @@ public class AccountsControllerTests : IDisposable private readonly IUserRepository _userRepository; private readonly IUserService _userService; private readonly ICaptchaValidationService _captchaValidationService; + private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector; public AccountsControllerTests() { @@ -29,11 +32,13 @@ public class AccountsControllerTests : IDisposable _userRepository = Substitute.For(); _userService = Substitute.For(); _captchaValidationService = Substitute.For(); + _assertionOptionsDataProtector = Substitute.For>(); _sut = new AccountsController( _logger, _userRepository, _userService, - _captchaValidationService + _captchaValidationService, + _assertionOptionsDataProtector ); } diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs new file mode 100644 index 000000000..604c7709b --- /dev/null +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -0,0 +1,172 @@ +using Bit.Core; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Identity.IdentityServer; +using Bit.Identity.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer; + +public class UserDecryptionOptionsBuilderTests +{ + private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; + private readonly IDeviceRepository _deviceRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly UserDecryptionOptionsBuilder _builder; + + public UserDecryptionOptionsBuilderTests() + { + _currentContext = Substitute.For(); + _featureService = Substitute.For(); + _deviceRepository = Substitute.For(); + _organizationUserRepository = Substitute.For(); + _builder = new UserDecryptionOptionsBuilder(_currentContext, _featureService, _deviceRepository, _organizationUserRepository); + } + + [Theory] + [BitAutoData(true, true, true)] // All keys are non-null + [BitAutoData(false, false, false)] // All keys are null + [BitAutoData(false, false, true)] // EncryptedUserKey is non-null, others are null + [BitAutoData(false, true, false)] // EncryptedPublicKey is non-null, others are null + [BitAutoData(true, false, false)] // EncryptedPrivateKey is non-null, others are null + [BitAutoData(true, false, true)] // EncryptedPrivateKey and EncryptedUserKey are non-null, EncryptedPublicKey is null + [BitAutoData(true, true, false)] // EncryptedPrivateKey and EncryptedPublicKey are non-null, EncryptedUserKey is null + [BitAutoData(false, true, true)] // EncryptedPublicKey and EncryptedUserKey are non-null, EncryptedPrivateKey is null + public async Task WithWebAuthnLoginCredential_VariousKeyCombinations_ShouldReturnCorrectPrfOption( + bool hasEncryptedPrivateKey, + bool hasEncryptedPublicKey, + bool hasEncryptedUserKey, + WebAuthnCredential credential) + { + credential.EncryptedPrivateKey = hasEncryptedPrivateKey ? "encryptedPrivateKey" : null; + credential.EncryptedPublicKey = hasEncryptedPublicKey ? "encryptedPublicKey" : null; + credential.EncryptedUserKey = hasEncryptedUserKey ? "encryptedUserKey" : null; + + var result = await _builder.WithWebAuthnLoginCredential(credential).BuildAsync(); + + if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled) + { + Assert.NotNull(result.WebAuthnPrfOption); + Assert.Equal(credential.EncryptedPrivateKey, result.WebAuthnPrfOption!.EncryptedPrivateKey); + Assert.Equal(credential.EncryptedUserKey, result.WebAuthnPrfOption!.EncryptedUserKey); + } + else + { + Assert.Null(result.WebAuthnPrfOption); + } + } + + [Theory, BitAutoData] + public async Task Build_WhenKeyConnectorIsEnabled_ShouldReturnKeyConnectorOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData) + { + configurationData.MemberDecryptionType = MemberDecryptionType.KeyConnector; + ssoConfig.Data = configurationData.Serialize(); + + var result = await _builder.WithSso(ssoConfig).BuildAsync(); + + Assert.NotNull(result.KeyConnectorOption); + Assert.Equal(configurationData.KeyConnectorUrl, result.KeyConnectorOption!.KeyConnectorUrl); + } + + [Theory, BitAutoData] + public async Task Build_WhenTrustedDeviceIsEnabled_ShouldReturnTrustedDeviceOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + + var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync(); + + Assert.NotNull(result.TrustedDeviceOption); + Assert.False(result.TrustedDeviceOption!.HasAdminApproval); + Assert.False(result.TrustedDeviceOption!.HasLoginApprovingDevice); + Assert.False(result.TrustedDeviceOption!.HasManageResetPasswordPermission); + } + + // TODO: Remove when FeatureFlagKeys.TrustedDeviceEncryption is removed + [Theory, BitAutoData] + public async Task Build_WhenTrustedDeviceIsEnabledButFeatureFlagIsDisabled_ShouldNotReturnTrustedDeviceOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(false); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + + var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync(); + + Assert.Null(result.TrustedDeviceOption); + } + + [Theory, BitAutoData] + public async Task Build_WhenDeviceIsTrusted_ShouldReturnKeys(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + device.EncryptedPrivateKey = "encryptedPrivateKey"; + device.EncryptedPublicKey = "encryptedPublicKey"; + device.EncryptedUserKey = "encryptedUserKey"; + + var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync(); + + Assert.Equal(device.EncryptedPrivateKey, result.TrustedDeviceOption?.EncryptedPrivateKey); + Assert.Equal(device.EncryptedUserKey, result.TrustedDeviceOption?.EncryptedUserKey); + } + + [Theory, BitAutoData] + public async Task Build_WhenHasLoginApprovingDevice_ShouldApprovingDeviceTrue(SsoConfig ssoConfig, SsoConfigurationData configurationData, User user, Device device, Device approvingDevice) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + approvingDevice.Type = LoginApprovingDeviceTypes.Types.First(); + _deviceRepository.GetManyByUserIdAsync(user.Id).Returns(new Device[] { approvingDevice }); + + var result = await _builder.ForUser(user).WithSso(ssoConfig).WithDevice(device).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasLoginApprovingDevice); + } + + [Theory, BitAutoData] + public async Task Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue( + SsoConfig ssoConfig, + SsoConfigurationData configurationData, + CurrentContextOrganization organization) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + ssoConfig.OrganizationId = organization.Id; + _currentContext.Organizations.Returns(new List(new CurrentContextOrganization[] { organization })); + _currentContext.ManageResetPassword(organization.Id).Returns(true); + + var result = await _builder.WithSso(ssoConfig).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); + } + + [Theory, BitAutoData] + public async Task Build_WhenUserHasEnrolledIntoPasswordReset_ShouldReturnHasAdminApprovalTrue( + SsoConfig ssoConfig, + SsoConfigurationData configurationData, + OrganizationUser organizationUser, + User user) + { + _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + organizationUser.ResetPasswordKey = "resetPasswordKey"; + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); + + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasAdminApproval); + } +}