mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
[PM-2032] Server endpoints to support authentication with a passkey (#3361)
* [PM-2032] feat: add assertion options tokenable * [PM-2032] feat: add request and response models * [PM-2032] feat: implement `assertion-options` identity endpoint * [PM-2032] feat: implement authentication with passkey * [PM-2032] chore: rename to `WebAuthnGrantValidator` * [PM-2032] fix: add missing subsitute * [PM-2032] feat: start adding builder * [PM-2032] feat: add support for KeyConnector * [PM-2032] feat: add first version of TDE * [PM-2032] chore: refactor WithSso * [PM-2023] feat: add support for TDE feature flag * [PM-2023] feat: add support for approving devices * [PM-2023] feat: add support for hasManageResetPasswordPermission * [PM-2032] feat: add support for hasAdminApproval * [PM-2032] chore: don't supply device if not necessary * [PM-2032] chore: clean up imports * [PM-2023] feat: extract interface * [PM-2023] chore: add clarifying comment * [PM-2023] feat: use new builder in production code * [PM-2032] feat: add support for PRF * [PM-2032] chore: clean-up todos * [PM-2023] chore: remove token which is no longer used * [PM-2032] chore: remove todo * [PM-2032] feat: improve assertion error handling * [PM-2032] fix: linting issues * [PM-2032] fix: revert changes to `launchSettings.json` * [PM-2023] chore: clean up assertion endpoint * [PM-2032] feat: bypass 2FA * [PM-2032] fix: rename prf option to singular * [PM-2032] fix: lint * [PM-2032] fix: typo * [PM-2032] chore: improve builder tests Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> * [PM-2032] chore: clarify why we don't require 2FA * [PM-2023] feat: move `identityProvider` constant to common class * [PM-2032] fix: lint * [PM-2023] fix: move `IdentityProvider` to core.Constants * [PM-2032] fix: missing import * [PM-2032] chore: refactor token timespan to use `TimeSpan` * [PM-2032] chore: make `StartWebAuthnLoginAssertion` sync * [PM-2032] chore: use `FromMinutes` * [PM-2032] fix: change to 17 minutes to cover webauthn assertion * [PM-2032] chore: do not use `async void` * [PM-2032] fix: comment saying wrong amount of minutes * [PM-2032] feat: put validator behind feature flag * [PM-2032] fix: lint --------- Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
This commit is contained in:
parent
07c202ecaf
commit
80740aa4ba
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
public enum WebAuthnLoginAssertionOptionsScope
|
||||
{
|
||||
Authentication = 0,
|
||||
PrfRegistration = 1
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
@ -16,6 +16,12 @@ public class UserDecryptionOptions : ResponseModel
|
||||
/// </summary>
|
||||
public bool HasMasterPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the WebAuthn PRF decryption keys.
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public WebAuthnPrfDecryptionOption? WebAuthnPrfOption { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets information regarding this users trusted device decryption setup.
|
||||
/// </summary>
|
||||
@ -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; }
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
19
src/Core/Auth/Utilities/GuidUtilities.cs
Normal file
19
src/Core/Auth/Utilities/GuidUtilities.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace Bit.Core.Auth.Utilities;
|
||||
|
||||
public static class GuidUtilities
|
||||
{
|
||||
public static bool TryParseBytes(ReadOnlySpan<byte> bytes, out Guid guid)
|
||||
{
|
||||
try
|
||||
{
|
||||
guid = new Guid(bytes);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
guid = Guid.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -38,6 +38,11 @@ public static class Constants
|
||||
/// regardless of whether there is a proration or not.
|
||||
/// </summary>
|
||||
public const string AlwaysInvoice = "always_invoice";
|
||||
|
||||
/// <summary>
|
||||
/// Used by IdentityServer to identify our own provider.
|
||||
/// </summary>
|
||||
public const string IdentityProvider = "bitwarden";
|
||||
}
|
||||
|
||||
public static class TokenPurposes
|
||||
|
@ -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<bool> CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse);
|
||||
Task<CredentialCreateOptions> StartWebAuthnLoginRegistrationAsync(User user);
|
||||
Task<bool> CompleteWebAuthLoginRegistrationAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null);
|
||||
Task<AssertionOptions> StartWebAuthnLoginAssertionAsync(User user);
|
||||
Task<string> CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user);
|
||||
AssertionOptions StartWebAuthnLoginAssertion();
|
||||
Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse);
|
||||
Task SendEmailVerificationAsync(User user);
|
||||
Task<IdentityResult> ConfirmEmailAsync(User user, string token);
|
||||
Task InitiateEmailChangeAsync(User user, string newEmail);
|
||||
|
@ -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<User>, IUserService, IDisposable
|
||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IStripeSyncService _stripeSyncService;
|
||||
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
|
||||
private readonly IDataProtectorTokenFactory<WebAuthnLoginTokenable> _webAuthnLoginTokenizer;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
@ -96,8 +96,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IStripeSyncService stripeSyncService,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IWebAuthnCredentialRepository webAuthnRepository,
|
||||
IDataProtectorTokenFactory<WebAuthnLoginTokenable> webAuthnLoginTokenizer)
|
||||
IWebAuthnCredentialRepository webAuthnRepository)
|
||||
: base(
|
||||
store,
|
||||
optionsAccessor,
|
||||
@ -136,7 +135,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
_stripeSyncService = stripeSyncService;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_webAuthnCredentialRepository = webAuthnRepository;
|
||||
_webAuthnLoginTokenizer = webAuthnLoginTokenizer;
|
||||
}
|
||||
|
||||
public Guid? GetProperUserId(ClaimsPrincipal principal)
|
||||
@ -586,45 +584,33 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<AssertionOptions> 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<PublicKeyCredentialDescriptor>(), UserVerificationRequirement.Required);
|
||||
}
|
||||
|
||||
public async Task<string> 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<User>, 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)
|
||||
|
@ -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<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
|
||||
|
||||
|
||||
public AccountsController(
|
||||
ILogger<AccountsController> logger,
|
||||
IUserRepository userRepository,
|
||||
IUserService userService,
|
||||
ICaptchaValidationService captchaValidationService)
|
||||
ICaptchaValidationService captchaValidationService,
|
||||
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> 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<AssertionOptions> 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<string> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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<T> where T : class
|
||||
private UserManager<User> _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<T> 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<User> userManager,
|
||||
@ -73,7 +72,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IDistributedCache distributedCache)
|
||||
IDistributedCache distributedCache,
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_deviceRepository = deviceRepository;
|
||||
@ -96,11 +96,12 @@ public abstract class BaseRequestValidator<T> 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<T> where T : class
|
||||
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
|
||||
protected abstract ClaimsPrincipal GetSubject(T context);
|
||||
|
||||
private async Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
|
||||
protected virtual async Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
|
||||
{
|
||||
if (request.GrantType == "client_credentials")
|
||||
{
|
||||
@ -612,67 +613,12 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
/// </summary>
|
||||
private async Task<UserDecryptionOptions> 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<SsoConfig?> GetSsoConfigurationDataAsync(ClaimsPrincipal subject)
|
||||
|
@ -44,12 +44,13 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
IPolicyService policyService,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> 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;
|
||||
}
|
||||
|
13
src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs
Normal file
13
src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs
Normal file
@ -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<UserDecryptionOptions> BuildAsync();
|
||||
}
|
@ -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<ResourceOwner
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> 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<ResourceOwner
|
||||
List<Claim> claims, Dictionary<string, object> 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;
|
||||
|
155
src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs
Normal file
155
src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs
Normal file
@ -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
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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<UserDecryptionOptions> 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);
|
||||
}
|
||||
}
|
150
src/Identity/IdentityServer/WebAuthnGrantValidator.cs
Normal file
150
src/Identity/IdentityServer/WebAuthnGrantValidator.cs
Normal file
@ -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<ExtensionGrantValidationContext>, IExtensionGrantValidator
|
||||
{
|
||||
public const string GrantType = "webauthn";
|
||||
|
||||
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
|
||||
|
||||
public WebAuthnGrantValidator(
|
||||
UserManager<User> userManager,
|
||||
IDeviceRepository deviceRepository,
|
||||
IDeviceService deviceService,
|
||||
IUserService userService,
|
||||
IEventService eventService,
|
||||
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IMailService mailService,
|
||||
ILogger<CustomTokenRequestValidator> logger,
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IUserRepository userRepository,
|
||||
IPolicyService policyService,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
|
||||
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> 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<AuthenticatorAssertionRawResponse>(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<bool> 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<Claim> claims, Dictionary<string, object> 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<Tuple<bool, Organization>> 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<bool, Organization>(false, null));
|
||||
}
|
||||
|
||||
protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.",
|
||||
customResponse);
|
||||
}
|
||||
|
||||
protected override void SetSsoResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.",
|
||||
customResponse);
|
||||
}
|
||||
|
||||
protected override void SetErrorResult(ExtensionGrantValidationContext context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
|
||||
}
|
||||
}
|
@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddSingleton<StaticClientStore>();
|
||||
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
|
||||
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
|
||||
|
||||
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
|
||||
var identityServerBuilder = services
|
||||
@ -44,7 +45,8 @@ public static class ServiceCollectionExtensions
|
||||
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
|
||||
.AddPersistedGrantStore<PersistedGrantStore>()
|
||||
.AddClientStore<ClientStore>()
|
||||
.AddIdentityServerCertificate(env, globalSettings);
|
||||
.AddIdentityServerCertificate(env, globalSettings)
|
||||
.AddExtensionGrantValidator<WebAuthnGrantValidator>();
|
||||
|
||||
services.AddTransient<ICorsPolicyService, CustomCorsPolicyService>();
|
||||
return identityServerBuilder;
|
||||
|
@ -168,18 +168,18 @@ public static class ServiceCollectionExtensions
|
||||
SsoTokenable.DataProtectorPurpose,
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<SsoTokenable>>>()));
|
||||
services.AddSingleton<IDataProtectorTokenFactory<WebAuthnLoginTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<WebAuthnLoginTokenable>(
|
||||
WebAuthnLoginTokenable.ClearTextPrefix,
|
||||
WebAuthnLoginTokenable.DataProtectorPurpose,
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<WebAuthnLoginTokenable>>>()));
|
||||
services.AddSingleton<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>(
|
||||
WebAuthnCredentialCreateOptionsTokenable.ClearTextPrefix,
|
||||
WebAuthnCredentialCreateOptionsTokenable.DataProtectorPurpose,
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>>()));
|
||||
services.AddSingleton<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>(
|
||||
WebAuthnLoginAssertionOptionsTokenable.ClearTextPrefix,
|
||||
WebAuthnLoginAssertionOptionsTokenable.DataProtectorPurpose,
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>>()));
|
||||
services.AddSingleton<IDataProtectorTokenFactory<SsoEmail2faSessionTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<SsoEmail2faSessionTokenable>(
|
||||
SsoEmail2faSessionTokenable.ClearTextPrefix,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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<UserService> sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator<WebAuthnCredential> credentialGenerator)
|
||||
public async Task CompleteWebAuthLoginRegistrationAsync_ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider<UserService> sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator<WebAuthnCredential> credentialGenerator)
|
||||
{
|
||||
// Arrange
|
||||
var existingCredentials = credentialGenerator.Take(5).ToList();
|
||||
@ -202,6 +206,92 @@ public class UserServiceTests
|
||||
sutProvider.GetDependency<IWebAuthnCredentialRepository>().DidNotReceive();
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteWebAuthLoginAssertionAsync_InvalidUserHandle_ThrowsBadRequestException(SutProvider<UserService> 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<BadRequestException>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteWebAuthLoginAssertionAsync_UserNotFound_ThrowsBadRequestException(SutProvider<UserService> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
|
||||
{
|
||||
// Arrange
|
||||
response.Response.UserHandle = user.Id.ToByteArray();
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).ReturnsNull();
|
||||
|
||||
// Act
|
||||
var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
|
||||
|
||||
// Assert
|
||||
await Assert.ThrowsAsync<BadRequestException>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteWebAuthLoginAssertionAsync_NoMatchingCredentialExists_ThrowsBadRequestException(SutProvider<UserService> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
|
||||
{
|
||||
// Arrange
|
||||
response.Response.UserHandle = user.Id.ToByteArray();
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
|
||||
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { });
|
||||
|
||||
// Act
|
||||
var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
|
||||
|
||||
// Assert
|
||||
await Assert.ThrowsAsync<BadRequestException>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteWebAuthLoginAssertionAsync_AssertionFails_ThrowsBadRequestException(SutProvider<UserService> 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<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
|
||||
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential });
|
||||
sutProvider.GetDependency<IFido2>().MakeAssertionAsync(response, options, Arg.Any<byte[]>(), Arg.Any<uint>(), Arg.Any<IsUserHandleOwnerOfCredentialIdAsync>())
|
||||
.Returns(assertionResult);
|
||||
|
||||
// Act
|
||||
var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
|
||||
|
||||
// Assert
|
||||
await Assert.ThrowsAsync<BadRequestException>(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteWebAuthLoginAssertionAsync_AssertionSucceeds_ReturnsUserAndCredential(SutProvider<UserService> 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<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
|
||||
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential });
|
||||
sutProvider.GetDependency<IFido2>().MakeAssertionAsync(response, options, Arg.Any<byte[]>(), Arg.Any<uint>(), Arg.Any<IsUserHandleOwnerOfCredentialIdAsync>())
|
||||
.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<IProviderUserRepository>(),
|
||||
sutProvider.GetDependency<IStripeSyncService>(),
|
||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
|
||||
sutProvider.GetDependency<IWebAuthnCredentialRepository>(),
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnLoginTokenable>>()
|
||||
sutProvider.GetDependency<IWebAuthnCredentialRepository>()
|
||||
);
|
||||
|
||||
var actualIsVerified = await sut.VerifySecretAsync(user, secret);
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
|
||||
|
||||
public AccountsControllerTests()
|
||||
{
|
||||
@ -29,11 +32,13 @@ public class AccountsControllerTests : IDisposable
|
||||
_userRepository = Substitute.For<IUserRepository>();
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_captchaValidationService = Substitute.For<ICaptchaValidationService>();
|
||||
_assertionOptionsDataProtector = Substitute.For<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>();
|
||||
_sut = new AccountsController(
|
||||
_logger,
|
||||
_userRepository,
|
||||
_userService,
|
||||
_captchaValidationService
|
||||
_captchaValidationService,
|
||||
_assertionOptionsDataProtector
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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<ICurrentContext>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_deviceRepository = Substitute.For<IDeviceRepository>();
|
||||
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
_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<CurrentContextOrganization>(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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user