diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 91ff038d98bd..bb8647d85133 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -19,13 +19,13 @@ using Bit.Core.Services; using Bit.Core.Tokens; using Bit.Core.Utilities; -using Fido2NetLib; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; +// TODO: PM-39393 - Clean up Obsolete endpoints in this controller [Route("two-factor")] [Authorize(Policies.Web)] public class TwoFactorController : Controller @@ -38,6 +38,8 @@ public class TwoFactorController : Controller private readonly IAuthRequestRepository _authRequestRepository; private readonly IDuoUniversalTokenService _duoUniversalTokenService; private readonly IDataProtectorTokenFactory _twoFactorAuthenticatorDataProtector; + private readonly IDataProtectorTokenFactory _twoFactorUserVerificationDataProtector; + private readonly ITwoFactorUserVerificationTokenableFactory _twoFactorUserVerificationTokenableFactory; private readonly IDataProtectorTokenFactory _ssoEmailTwoFactorSessionDataProtector; private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly IStartTwoFactorWebAuthnRegistrationCommand _startTwoFactorWebAuthnRegistrationCommand; @@ -53,6 +55,8 @@ public TwoFactorController( IAuthRequestRepository authRequestRepository, IDuoUniversalTokenService duoUniversalConfigService, IDataProtectorTokenFactory twoFactorAuthenticatorDataProtector, + IDataProtectorTokenFactory twoFactorUserVerificationDataProtector, + ITwoFactorUserVerificationTokenableFactory twoFactorUserVerificationTokenableFactory, IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector, ITwoFactorEmailService twoFactorEmailService, IStartTwoFactorWebAuthnRegistrationCommand startTwoFactorWebAuthnRegistrationCommand, @@ -67,6 +71,8 @@ public TwoFactorController( _authRequestRepository = authRequestRepository; _duoUniversalTokenService = duoUniversalConfigService; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; + _twoFactorUserVerificationDataProtector = twoFactorUserVerificationDataProtector; + _twoFactorUserVerificationTokenableFactory = twoFactorUserVerificationTokenableFactory; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; _twoFactorEmailService = twoFactorEmailService; _startTwoFactorWebAuthnRegistrationCommand = startTwoFactorWebAuthnRegistrationCommand; @@ -112,7 +118,7 @@ public async Task> GetOrganiza public async Task GetAuthenticator( [FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserBySecretAsync(model); var response = new TwoFactorAuthenticatorResponseModel(user); var tokenable = new TwoFactorAuthenticatorUserVerificationTokenable(user, response.Key); response.UserVerificationToken = _twoFactorAuthenticatorDataProtector.Protect(tokenable); @@ -121,7 +127,7 @@ public async Task GetAuthenticator( [HttpPut("authenticator")] public async Task PutAuthenticator( - [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) + [FromBody] TwoFactorAuthenticatorUpdateRequestModel model) { var user = model.ToUser(await _userService.GetUserByPrincipalAsync(User)); @@ -138,7 +144,6 @@ public async Task PutAuthenticator( if (!await _userManager.VerifyTwoFactorTokenAsync(user, CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), model.Token)) { - await Task.Delay(2000); throw new BadRequestException("Token", "Invalid token."); } @@ -150,14 +155,14 @@ public async Task PutAuthenticator( [HttpPost("authenticator")] [Obsolete("This endpoint is deprecated. Use PUT /authenticator instead.")] public async Task PostAuthenticator( - [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) + [FromBody] TwoFactorAuthenticatorUpdateRequestModel model) { return await PutAuthenticator(model); } [HttpDelete("authenticator")] - public async Task DisableAuthenticator( - [FromBody] TwoFactorAuthenticatorDisableRequestModel model) + public async Task DeleteAuthenticator( + [FromBody] TwoFactorAuthenticatorDeleteRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -171,22 +176,33 @@ public async Task DisableAuthenticator( throw new BadRequestException("UserVerificationToken", "User verification failed."); } - await _userService.DisableTwoFactorProviderAsync(user, model.Type.Value); - return new TwoFactorProviderResponseModel(model.Type.Value, user); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.Authenticator, user); } [HttpPost("get-yubikey")] public async Task GetYubiKey([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, true, true); - var response = new TwoFactorYubiKeyResponseModel(user); - return response; + var user = await ValidateUserBySecretAsync(model); + return new TwoFactorYubiKeyResponseModel(user) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.YubiKey), + }; + } + + [HttpDelete("yubikey")] + public async Task DeleteYubiKey([FromBody] TwoFactorYubiKeyDeleteRequestModel model) + { + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.YubiKey); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.YubiKey, user); } [HttpPut("yubikey")] - public async Task PutYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) + public async Task PutYubiKey([FromBody] TwoFactorYubiKeyUpdateRequestModel model) { - var user = await CheckAsync(model, true); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.YubiKey); + await ValidateUserHasPremiumAsync(user); model.ToUser(user); await ValidateYubiKeyAsync(user, nameof(model.Key1), model.Key1); @@ -202,7 +218,7 @@ public async Task PutYubiKey([FromBody] UpdateTwo [HttpPost("yubikey")] [Obsolete("This endpoint is deprecated. Use PUT /yubikey instead.")] - public async Task PostYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) + public async Task PostYubiKey([FromBody] TwoFactorYubiKeyUpdateRequestModel model) { return await PutYubiKey(model); } @@ -210,15 +226,26 @@ public async Task PostYubiKey([FromBody] UpdateTw [HttpPost("get-duo")] public async Task GetDuo([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, true, true); - var response = new TwoFactorDuoResponseModel(user); - return response; + var user = await ValidateUserBySecretAsync(model); + return new TwoFactorDuoResponseModel(user) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.Duo), + }; + } + + [HttpDelete("duo")] + public async Task DeleteDuo([FromBody] TwoFactorDuoDeleteRequestModel model) + { + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Duo); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Duo); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.Duo, user); } [HttpPut("duo")] - public async Task PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model) + public async Task PutDuo([FromBody] TwoFactorDuoUpdateRequestModel model) { - var user = await CheckAsync(model, true); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Duo); + await ValidateUserHasPremiumAsync(user); if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host)) { throw new BadRequestException( @@ -233,7 +260,7 @@ public async Task PutDuo([FromBody] UpdateTwoFactorDu [HttpPost("duo")] [Obsolete("This endpoint is deprecated. Use PUT /duo instead.")] - public async Task PostDuo([FromBody] UpdateTwoFactorDuoRequestModel model) + public async Task PostDuo([FromBody] TwoFactorDuoUpdateRequestModel model) { return await PutDuo(model); } @@ -242,7 +269,7 @@ public async Task PostDuo([FromBody] UpdateTwoFactorD public async Task GetOrganizationDuo(string id, [FromBody] SecretVerificationRequestModel model) { - await CheckAsync(model, false, true); + var user = await ValidateUserBySecretAsync(model); var orgIdGuid = new Guid(id); if (!await _currentContext.ManagePolicies(orgIdGuid)) @@ -251,15 +278,34 @@ public async Task GetOrganizationDuo(string id, } var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException(); - var response = new TwoFactorDuoResponseModel(organization); - return response; + return new TwoFactorDuoResponseModel(organization) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.OrganizationDuo), + }; + } + + [HttpDelete("~/organizations/{id}/two-factor/duo")] + public async Task DeleteOrganizationDuo(string id, + [FromBody] TwoFactorOrganizationDuoDeleteRequestModel model) + { + await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.OrganizationDuo); + + var orgIdGuid = new Guid(id); + if (!await _currentContext.ManagePolicies(orgIdGuid)) + { + throw new NotFoundException(); + } + + var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException(); + await _organizationService.DisableTwoFactorProviderAsync(organization, TwoFactorProviderType.OrganizationDuo); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.OrganizationDuo, organization); } [HttpPut("~/organizations/{id}/two-factor/duo")] public async Task PutOrganizationDuo(string id, - [FromBody] UpdateTwoFactorDuoRequestModel model) + [FromBody] TwoFactorDuoUpdateRequestModel model) { - await CheckAsync(model, false); + await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.OrganizationDuo); var orgIdGuid = new Guid(id); if (!await _currentContext.ManagePolicies(orgIdGuid)) @@ -284,7 +330,7 @@ await _organizationService.UpdateTwoFactorProviderAsync(organization, [HttpPost("~/organizations/{id}/two-factor/duo")] [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/duo instead.")] public async Task PostOrganizationDuo(string id, - [FromBody] UpdateTwoFactorDuoRequestModel model) + [FromBody] TwoFactorDuoUpdateRequestModel model) { return await PutOrganizationDuo(id, model); } @@ -292,24 +338,30 @@ public async Task PostOrganizationDuo(string id, [HttpPost("get-webauthn")] public async Task GetWebAuthn([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false, true); - var response = new TwoFactorWebAuthnResponseModel(user); - return response; + var user = await ValidateUserBySecretAsync(model); + return new TwoFactorWebAuthnResponseModel(user) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.WebAuthn), + }; } [HttpPost("get-webauthn-challenge")] [ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly - public async Task GetWebAuthnChallenge([FromBody] SecretVerificationRequestModel model) + public async Task GetWebAuthnChallenge([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false, true); - var reg = await _startTwoFactorWebAuthnRegistrationCommand.StartTwoFactorWebAuthnRegistrationAsync(user); - return reg; + var user = await ValidateUserBySecretAsync(model); + var options = await _startTwoFactorWebAuthnRegistrationCommand.StartTwoFactorWebAuthnRegistrationAsync(user); + return new TwoFactorWebAuthnChallengeResponseModel + { + Options = options, + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.WebAuthn), + }; } [HttpPut("webauthn")] - public async Task PutWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) + public async Task PutWebAuthn([FromBody] TwoFactorWebAuthnUpdateRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn); var success = await _completeTwoFactorWebAuthnRegistrationCommand.CompleteTwoFactorWebAuthnRegistrationAsync( user, model.Id.Value, model.Name, model.DeviceResponse); @@ -324,7 +376,7 @@ public async Task PutWebAuthn([FromBody] TwoFact [HttpPost("webauthn")] [Obsolete("This endpoint is deprecated. Use PUT /webauthn instead.")] - public async Task PostWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) + public async Task PostWebAuthn([FromBody] TwoFactorWebAuthnUpdateRequestModel model) { return await PutWebAuthn(model); } @@ -333,7 +385,7 @@ public async Task PostWebAuthn([FromBody] TwoFac public async Task DeleteWebAuthn( [FromBody] TwoFactorWebAuthnDeleteRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn); if (!model.Id.HasValue) { @@ -350,23 +402,41 @@ public async Task DeleteWebAuthn( return response; } + [HttpDelete("webauthn/all")] + public async Task DeleteWebAuthnAll( + [FromBody] TwoFactorWebAuthnDeleteAllRequestModel model) + { + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.WebAuthn, user); + } + [HttpPost("get-email")] public async Task GetEmail([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false, true); - var response = new TwoFactorEmailResponseModel(user); - return response; + var user = await ValidateUserBySecretAsync(model); + return new TwoFactorEmailResponseModel(user) + { + UserVerificationToken = MintProtectedUserVerificationToken(user, TwoFactorProviderType.Email), + }; + } + + [HttpDelete("email")] + public async Task DeleteEmail([FromBody] TwoFactorEmailDeleteRequestModel model) + { + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); + await _userService.DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + return new TwoFactorProviderResponseModel(TwoFactorProviderType.Email, user); } /// - /// This endpoint is only used to set-up email two factor authentication. + /// This endpoint is only used to set-up email two factor authentication. The client must first + /// call get-email to obtain a user-verification token, then replay that token here. /// - /// secret verification model - /// void [HttpPost("send-email")] - public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model) + public async Task SendEmail([FromBody] TwoFactorEmailSetupRequestModel model) { - var user = await CheckAsync(model, false, true); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); // Add email to the user's 2FA providers, with the email address they've provided. model.ToUser(user); await _twoFactorEmailService.SendTwoFactorSetupEmailAsync(user); @@ -374,7 +444,7 @@ public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model) [AllowAnonymous] [HttpPost("send-email-login")] - public async Task SendEmailLoginAsync([FromBody] TwoFactorEmailRequestModel requestModel) + public async Task SendEmailLoginAsync([FromBody] TwoFactorEmailLoginRequestModel requestModel) { var user = await _userManager.FindByEmailAsync(requestModel.Email.ToLowerInvariant()); @@ -413,15 +483,14 @@ await ThrowDelayedBadRequestExceptionAsync( } [HttpPut("email")] - public async Task PutEmail([FromBody] UpdateTwoFactorEmailRequestModel model) + public async Task PutEmail([FromBody] TwoFactorEmailUpdateRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.Email); model.ToUser(user); if (!await _userManager.VerifyTwoFactorTokenAsync(user, CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), model.Token)) { - await Task.Delay(2000); throw new BadRequestException("Token", "Invalid token."); } @@ -432,62 +501,15 @@ public async Task PutEmail([FromBody] UpdateTwoFact [HttpPost("email")] [Obsolete("This endpoint is deprecated. Use PUT /email instead.")] - public async Task PostEmail([FromBody] UpdateTwoFactorEmailRequestModel model) + public async Task PostEmail([FromBody] TwoFactorEmailUpdateRequestModel model) { return await PutEmail(model); } - [HttpPut("disable")] - public async Task PutDisable([FromBody] TwoFactorProviderRequestModel model) - { - var user = await CheckAsync(model, false); - await _userService.DisableTwoFactorProviderAsync(user, model.Type.Value); - var response = new TwoFactorProviderResponseModel(model.Type.Value, user); - return response; - } - - [HttpPost("disable")] - [Obsolete("This endpoint is deprecated. Use PUT /disable instead.")] - public async Task PostDisable([FromBody] TwoFactorProviderRequestModel model) - { - return await PutDisable(model); - } - - [HttpPut("~/organizations/{id}/two-factor/disable")] - public async Task PutOrganizationDisable(string id, - [FromBody] TwoFactorProviderRequestModel model) - { - await CheckAsync(model, false); - - var orgIdGuid = new Guid(id); - if (!await _currentContext.ManagePolicies(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - await _organizationService.DisableTwoFactorProviderAsync(organization, model.Type.Value); - var response = new TwoFactorProviderResponseModel(model.Type.Value, organization); - return response; - } - - [HttpPost("~/organizations/{id}/two-factor/disable")] - [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/disable instead.")] - public async Task PostOrganizationDisable(string id, - [FromBody] TwoFactorProviderRequestModel model) - { - return await PutOrganizationDisable(id, model); - } - [HttpPost("get-recover")] public async Task GetRecover([FromBody] SecretVerificationRequestModel model) { - var user = await CheckAsync(model, false); + var user = await ValidateUserBySecretAsync(model); var response = new TwoFactorRecoverResponseModel(user); return response; } @@ -507,8 +529,10 @@ public Task PutDeviceVerificationSettings( return Task.FromResult(new DeviceVerificationResponseModel(false, false)); } - private async Task CheckAsync(SecretVerificationRequestModel model, bool premium, - bool skipVerification = false) + /// Verifies the principal user's secret (master password or OTP) and returns the user. + /// No authenticated user. + /// Secret does not verify. + private async Task ValidateUserBySecretAsync(SecretVerificationRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -516,20 +540,49 @@ private async Task CheckAsync(SecretVerificationRequestModel model, bool p throw new UnauthorizedAccessException(); } - if (!await _userService.VerifySecretAsync(user, model.Secret, skipVerification)) + if (!await _userService.VerifySecretAsync(user, model.Secret)) { - await Task.Delay(2000); throw new BadRequestException(string.Empty, "User verification failed."); } + return user; + } - if (premium && !await _userService.CanAccessPremium(user)) + /// Verifies a user-verification token is bound to the principal user and the given provider, and returns the user. + /// No authenticated user. + /// Token cannot be unprotected, has expired, or is not bound to this user and provider. + private async Task ValidateUserVerificationTokenAsync(string userVerificationToken, TwoFactorProviderType providerType) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) { - throw new BadRequestException("Premium status is required."); + throw new UnauthorizedAccessException(); } + if (!TwoFactorUserVerificationTokenable.Validate( + _twoFactorUserVerificationDataProtector, userVerificationToken, user, providerType)) + { + throw new BadRequestException("UserVerificationToken", "User verification failed."); + } return user; } + /// Verifies the user has premium access. + /// User cannot access premium. + private async Task ValidateUserHasPremiumAsync(User user) + { + if (!await _userService.CanAccessPremium(user)) + { + throw new BadRequestException("Premium status is required."); + } + } + + /// Mints a protected user-verification token bound to and . + private string MintProtectedUserVerificationToken(User user, TwoFactorProviderType providerType) + { + var token = _twoFactorUserVerificationTokenableFactory.CreateToken(user, providerType); + return _twoFactorUserVerificationDataProtector.Protect(token); + } + private async Task ValidateYubiKeyAsync(User user, string name, string value) { if (string.IsNullOrWhiteSpace(value) || value.Length == 12) diff --git a/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs new file mode 100644 index 000000000000..a7000b79775a --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request model for deleting a user's Duo two-factor configuration. +public class TwoFactorDuoDeleteRequestModel +{ + /// Token minted by GetDuo; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs new file mode 100644 index 000000000000..002897266f58 --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request model for deleting a user's Email two-factor configuration. +public class TwoFactorEmailDeleteRequestModel +{ + /// Token minted by GetEmail; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs new file mode 100644 index 000000000000..7ca6d2f7ac46 --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorOrganizationDuoDeleteRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request model for deleting an organization's Duo two-factor configuration. +public class TwoFactorOrganizationDuoDeleteRequestModel +{ + /// Token minted by GetOrganizationDuo; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 6173de81d9b5..3af5d21b1367 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -12,15 +12,23 @@ namespace Bit.Api.Auth.Models.Request; -public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationRequestModel +/// Request model for setting up or updating a user's Authenticator (TOTP) two-factor configuration. +public class TwoFactorAuthenticatorUpdateRequestModel { + /// Six-digit TOTP code from the authenticator app, proving the user enrolled . [Required] [StringLength(50)] public string Token { get; set; } + + /// TOTP shared secret that the token was minted against; must match the token's bound Key. [Required] [StringLength(50)] public string Key { get; set; } + + /// Token minted by GetAuthenticator; bound to UserId + Key. + [Required] public string UserVerificationToken { get; set; } + public User ToUser(User existingUser) { var providers = existingUser.GetTwoFactorProviders(); @@ -43,7 +51,7 @@ public User ToUser(User existingUser) } } -public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject +public class TwoFactorDuoUpdateRequestModel : IValidatableObject { /* String lengths based on Duo's documentation @@ -57,6 +65,8 @@ String lengths based on Duo's documentation public string ClientSecret { get; set; } [Required] public string Host { get; set; } + [Required] + public string UserVerificationToken { get; set; } public User ToUser(User existingUser) { @@ -110,7 +120,7 @@ public Organization ToOrganization(Organization existingOrg) return existingOrg; } - public override IEnumerable Validate(ValidationContext validationContext) + public IEnumerable Validate(ValidationContext validationContext) { var results = new List(); if (string.IsNullOrWhiteSpace(ClientId)) @@ -131,7 +141,7 @@ public override IEnumerable Validate(ValidationContext validat } } -public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestModel, IValidatableObject +public class TwoFactorYubiKeyUpdateRequestModel : IValidatableObject { public string Key1 { get; set; } public string Key2 { get; set; } @@ -140,6 +150,8 @@ public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestMod public string Key5 { get; set; } [Required] public bool? Nfc { get; set; } + [Required] + public string UserVerificationToken { get; set; } public User ToUser(User existingUser) { @@ -180,7 +192,7 @@ private string FormatKey(string keyValue) return keyValue.Substring(0, 12); } - public override IEnumerable Validate(ValidationContext validationContext) + public IEnumerable Validate(ValidationContext validationContext) { if (string.IsNullOrWhiteSpace(Key1) && string.IsNullOrWhiteSpace(Key2) && string.IsNullOrWhiteSpace(Key3) && string.IsNullOrWhiteSpace(Key4) && string.IsNullOrWhiteSpace(Key5)) @@ -215,7 +227,11 @@ public override IEnumerable Validate(ValidationContext validat } } -public class TwoFactorEmailRequestModel : SecretVerificationRequestModel +/// +/// Request body for the anonymous login-time endpoint that emails a 2FA OTP during sign-in. Authenticated +/// by master password / OTP, SSO email-2FA session token, or device-auth-request access code. +/// +public class TwoFactorEmailLoginRequestModel : SecretVerificationRequestModel { [Required] [EmailAddress] @@ -224,55 +240,34 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel public string AuthRequestId { get; set; } // An auth session token used for obtaining email and as an authN factor for the sending of emailed 2FA OTPs. public string SsoEmail2FaSessionToken { get; set; } - public User ToUser(User existingUser) - { - var providers = existingUser.GetTwoFactorProviders(); - if (providers == null) - { - providers = new Dictionary(); - } - else - { - providers.Remove(TwoFactorProviderType.Email); - } - - providers.Add(TwoFactorProviderType.Email, new TwoFactorProvider - { - MetaData = new Dictionary { ["Email"] = Email.ToLowerInvariant() }, - Enabled = true - }); - existingUser.SetTwoFactorProviders(providers); - return existingUser; - } public override IEnumerable Validate(ValidationContext validationContext) { - if (string.IsNullOrEmpty(Secret) && string.IsNullOrEmpty(AuthRequestAccessCode) && string.IsNullOrEmpty((SsoEmail2FaSessionToken))) + if (string.IsNullOrEmpty(Secret) + && string.IsNullOrEmpty(AuthRequestAccessCode) + && string.IsNullOrEmpty(SsoEmail2FaSessionToken)) { yield return new ValidationResult("MasterPasswordHash, OTP, AccessCode, or SsoEmail2faSessionToken must be supplied."); } } } -public class TwoFactorWebAuthnRequestModel : TwoFactorWebAuthnDeleteRequestModel +public class TwoFactorWebAuthnUpdateRequestModel : TwoFactorWebAuthnDeleteRequestModel { [Required] public AuthenticatorAttestationRawResponse DeviceResponse { get; set; } public string Name { get; set; } } -public class TwoFactorWebAuthnDeleteRequestModel : SecretVerificationRequestModel, IValidatableObject +public class TwoFactorWebAuthnDeleteRequestModel : IValidatableObject { [Required] public int? Id { get; set; } + [Required] + public string UserVerificationToken { get; set; } - public override IEnumerable Validate(ValidationContext validationContext) + public IEnumerable Validate(ValidationContext validationContext) { - foreach (var validationResult in base.Validate(validationContext)) - { - yield return validationResult; - } - if (!Id.HasValue) { yield return new ValidationResult("Invalid Key Id", new string[] { nameof(Id) }); @@ -280,30 +275,61 @@ public override IEnumerable Validate(ValidationContext validat } } -public class UpdateTwoFactorEmailRequestModel : TwoFactorEmailRequestModel +/// +/// Request body for the authenticated setup endpoint that sends a verification OTP to the user's chosen +/// 2FA email address. Authenticated by a user-verification token minted earlier in the setup flow. +/// +public class TwoFactorEmailSetupRequestModel { [Required] - [StringLength(50)] - public string Token { get; set; } -} + [EmailAddress] + [StringLength(256)] + public string Email { get; set; } -public class TwoFactorProviderRequestModel : SecretVerificationRequestModel -{ [Required] - public TwoFactorProviderType? Type { get; set; } + public string UserVerificationToken { get; set; } + + public User ToUser(User existingUser) + { + var providers = existingUser.GetTwoFactorProviders(); + if (providers == null) + { + providers = new Dictionary(); + } + else + { + providers.Remove(TwoFactorProviderType.Email); + } + + providers.Add(TwoFactorProviderType.Email, new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = Email.ToLowerInvariant() }, + Enabled = true + }); + existingUser.SetTwoFactorProviders(providers); + return existingUser; + } } -public class TwoFactorRecoveryRequestModel : TwoFactorEmailRequestModel +/// +/// Request body for the authenticated setup endpoint that completes Email 2FA enrollment by replaying the +/// OTP from the previous setup step. Authenticated by the same user-verification token. +/// +public class TwoFactorEmailUpdateRequestModel : TwoFactorEmailSetupRequestModel { [Required] - [StringLength(32)] - public string RecoveryCode { get; set; } + [StringLength(50)] + public string Token { get; set; } } -public class TwoFactorAuthenticatorDisableRequestModel : TwoFactorProviderRequestModel +/// Request model for deleting a user's Authenticator (TOTP) two-factor configuration. +public class TwoFactorAuthenticatorDeleteRequestModel { + /// Token minted by GetAuthenticator; bound to UserId + Key. [Required] public string UserVerificationToken { get; set; } + + /// TOTP shared secret that the token was minted against; must match the token's bound Key. [Required] public string Key { get; set; } } diff --git a/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs new file mode 100644 index 000000000000..6e6ee0d68308 --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorWebAuthnDeleteAllRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request model for deleting all of a user's WebAuthn two-factor credentials. +public class TwoFactorWebAuthnDeleteAllRequestModel +{ + /// Token minted by GetWebAuthn; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs b/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs new file mode 100644 index 000000000000..23169a8a3de6 --- /dev/null +++ b/src/Api/Auth/Models/Request/TwoFactorYubiKeyDeleteRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Auth.Models.Request; + +/// Request model for deleting a user's YubiKey two-factor configuration. +public class TwoFactorYubiKeyDeleteRequestModel +{ + /// Token minted by GetYubiKey; bound to UserId + ProviderType. + [Required] + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs index e7e29d06cb77..01a8fea7dcf8 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs @@ -35,6 +35,7 @@ public TwoFactorDuoResponseModel(Organization organization) public string Host { get; set; } public string ClientSecret { get; set; } public string ClientId { get; set; } + public string UserVerificationToken { get; set; } private void Build(TwoFactorProvider provider) { diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs index e16f2a6b783b..f6787177983b 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs @@ -31,4 +31,5 @@ public TwoFactorEmailResponseModel(User user) public bool Enabled { get; set; } public string Email { get; set; } + public string UserVerificationToken { get; set; } } diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnChallengeResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnChallengeResponseModel.cs new file mode 100644 index 000000000000..b0bb6ca9cf1e --- /dev/null +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnChallengeResponseModel.cs @@ -0,0 +1,21 @@ +using Bit.Core.Models.Api; +using Fido2NetLib; + +namespace Bit.Api.Auth.Models.Response.TwoFactor; + +/// +/// Envelope around that adds the user-verification token. +/// +public class TwoFactorWebAuthnChallengeResponseModel : ResponseModel +{ + public TwoFactorWebAuthnChallengeResponseModel() + : base("twoFactorWebAuthnChallenge") + { + } + + /// FIDO2 registration ceremony options; passed straight to navigator.credentials.create(). + public CredentialCreateOptions Options { get; set; } = null!; + + /// Token to replay on the subsequent PUT so the user does not have to re-verify. + public string UserVerificationToken { get; set; } = null!; +} diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs index cd853e57398c..66b62ce17847 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs @@ -27,6 +27,7 @@ public TwoFactorWebAuthnResponseModel(User user) public bool Enabled { get; set; } public IEnumerable Keys { get; set; } + public string UserVerificationToken { get; set; } public class KeyModel { diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs index 10cc6749e6aa..d8626085ff88 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs @@ -60,4 +60,5 @@ public TwoFactorYubiKeyResponseModel(User user) public string Key4 { get; set; } public string Key5 { get; set; } public bool Nfc { get; set; } + public string UserVerificationToken { get; set; } } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index e96224ff02e4..4b9a50e2d883 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -20,6 +20,13 @@ public interface IOrganizationService Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); Task UpdateAsync(Organization organization, bool updateBilling = false); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); + /// + /// Removes the entry for from the organization's TwoFactorProviders JSON column. + /// The provider's MetaData (Duo Host / ClientId / ClientSecret) is destroyed in the process; the name is + /// historical — this is a hard delete of the provider configuration, not a reversible disable. No-op if + /// is not currently configured. Throws if + /// is not an organization-scoped provider type. + /// Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, OrganizationUserInvite invite, string externalId); diff --git a/src/Core/Auth/Models/Business/Tokenables/ITwoFactorUserVerificationTokenableFactory.cs b/src/Core/Auth/Models/Business/Tokenables/ITwoFactorUserVerificationTokenableFactory.cs new file mode 100644 index 000000000000..914abc7ae3c9 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/ITwoFactorUserVerificationTokenableFactory.cs @@ -0,0 +1,11 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +/// Mints instances with the operator-configured lifetime. +public interface ITwoFactorUserVerificationTokenableFactory +{ + /// Creates a token bound to the given user and provider, expiring after the configured lifetime. + TwoFactorUserVerificationTokenable CreateToken(User user, TwoFactorProviderType providerType); +} diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs new file mode 100644 index 000000000000..d28ca96226ef --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; +using Bit.Core.Tokens; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +/// +/// Time-limited proof that a user passed entry-level secret verification (master password or OTP). +/// Issued by per-provider GET endpoints (e.g. GetYubiKey) and replayed on the subsequent +/// PUT / DELETE within the token's lifetime so the user does not need to re-verify. Bound to +/// + to prevent cross-provider replay. +/// +public class TwoFactorUserVerificationTokenable : ExpiringTokenable +{ + public const string ClearTextPrefix = "TwoFactorUserVerification_"; + public const string DataProtectorPurpose = "TwoFactorUserVerificationTokenDataProtector"; + public const string TokenIdentifier = "TwoFactorUserVerificationToken"; + + public string Identifier { get; set; } = TokenIdentifier; + public Guid UserId { get; set; } + public TwoFactorProviderType ProviderType { get; set; } + + /// + /// Required for JsonSerializer.Deserialize<T> inside DataProtectorTokenFactory.Unprotect, + /// which deserializes the decrypted JSON into this type. Production code must mint via + /// so the + /// IGlobalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes value applies — a + /// direct new() yields ExpirationDate == default and fails . + /// + [JsonConstructor] + public TwoFactorUserVerificationTokenable() { } + + /// Returns true iff this token was minted for the given user and provider. + public bool TokenIsValid(User user, TwoFactorProviderType providerType) => + user is not null + && UserId != default + && UserId == user.Id + && ProviderType == providerType; + + /// + protected override bool TokenIsValid() => + Identifier == TokenIdentifier && UserId != default; + + /// + /// Unprotect, validate expiry + identifier, and verify the user/provider binding in one call. + /// + public static bool Validate( + IDataProtectorTokenFactory factory, + string token, + User user, + TwoFactorProviderType providerType) => + factory.TryUnprotect(token, out var decryptedToken) + && decryptedToken.Valid + && decryptedToken.TokenIsValid(user, providerType); +} diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactory.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactory.cs new file mode 100644 index 000000000000..5030050242cc --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactory.cs @@ -0,0 +1,26 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; +using Bit.Core.Settings; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +/// +public class TwoFactorUserVerificationTokenableFactory : ITwoFactorUserVerificationTokenableFactory +{ + private readonly IGlobalSettings _globalSettings; + + public TwoFactorUserVerificationTokenableFactory(IGlobalSettings globalSettings) + { + _globalSettings = globalSettings; + } + + /// + public TwoFactorUserVerificationTokenable CreateToken(User user, TwoFactorProviderType providerType) => + new() + { + UserId = user.Id, + ProviderType = providerType, + ExpirationDate = DateTime.UtcNow.Add( + TimeSpan.FromMinutes(_globalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes)), + }; +} diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md b/src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md new file mode 100644 index 000000000000..d85e26661ec3 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md @@ -0,0 +1,79 @@ +# Two-Factor Authentication + +This area of the codebase covers enrollment and management of the multi-factor authentication providers Bitwarden supports — Authenticator (TOTP), YubiKey OTP, Duo (personal and organization), WebAuthn, and Email — plus supporting flows such as recovery codes, login-time challenges, and administrative resets. The sections below document specific aspects of the 2FA domain. + +## User Verification + +When a user manages their own 2FA enrollment (configuring a new provider, updating an existing one, removing one), the server requires proof that the human at the keyboard is the account owner. This section covers how that proof is established and replayed across the read → write step of a per-provider management flow. + +### The flow + +``` + ┌─────────────────────────────────────────────┐ + │ GET /two-factor/get- │ + │ authenticate with master password / OTP │ + │ → returns { config, userVerificationToken } + └─────────────────────────────────────────────┘ + │ + │ (client holds the token) + ▼ + ┌─────────────────────────────┬─────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + PUT /two-factor/

DELETE /two-factor/

token lifetime expires + { …config, token } { token } → user re-enters secret + → updates enrollment → removes enrollment on the next GET +``` + +The GET endpoint is the only step that requires the master-password / OTP secret. After it succeeds, the server mints a short-lived **user-verification token** and returns it alongside the provider's current config. The client replays that token on the subsequent PUT or DELETE; no secret is sent on the write step. + +### The token + +`TwoFactorUserVerificationTokenable` (in [`src/Core/Auth/Models/Business/Tokenables/`](../../Models/Business/Tokenables/TwoFactorUserVerificationTokenable.cs)) carries: + +| Field | Purpose | +| ---------------- | ----------------------------------------------------------------------------------------------------------- | +| `UserId` | binds the token to one user | +| `ProviderType` | binds the token to one provider (`Authenticator`, `YubiKey`, `Duo`, `OrganizationDuo`, `WebAuthn`, `Email`) | +| `ExpirationDate` | enforces the lifetime | +| `Identifier` | distinguishes this tokenable from other token types that share the data-protector serialization layer | + +The token is data-protected (encrypted + integrity-checked) by `IDataProtectorTokenFactory` before being handed to the client, and unprotected on the way back in. + +Minting goes through `ITwoFactorUserVerificationTokenableFactory` so the `IGlobalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes` value is honored consistently (default: 30 minutes). Constructing a `TwoFactorUserVerificationTokenable` directly via `new()` yields `ExpirationDate == default` and is always invalid — fail-closed by design. + +### Validation rules + +When a PUT or DELETE endpoint receives a `userVerificationToken`, the controller's `ValidateUserVerificationTokenAsync` helper enforces: + +1. **Unprotection** — the token decrypts and deserializes cleanly. Mangled or unknown tokens are rejected. +2. **Validity window** — `ExpirationDate > now`. Expired tokens are rejected. +3. **User binding** — `tokenable.UserId == currentUser.Id`. A token minted for User A is rejected when replayed against User B's endpoint. +4. **Provider binding** — `tokenable.ProviderType == expectedProviderType`. A token minted for YubiKey is rejected when replayed against the Duo PUT or the Email DELETE, etc. + +Any failure produces `BadRequestException("UserVerificationToken", "User verification failed.")`. + +Replays of a still-valid token against the same `(UserId, ProviderType)` are intentionally allowed — there's no server-side single-use enforcement. Expiration is the only bound on how long a UV session lasts. + +### Which endpoints participate + +Every per-provider 2FA management endpoint follows the same shape: + +| Provider | GET (mints) | PUT (consumes) | DELETE (consumes) | +| -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Authenticator | `POST /two-factor/get-authenticator` | `PUT /two-factor/authenticator` | `DELETE /two-factor/authenticator` | +| YubiKey | `POST /two-factor/get-yubikey` | `PUT /two-factor/yubikey` | `DELETE /two-factor/yubikey` | +| Duo (personal) | `POST /two-factor/get-duo` | `PUT /two-factor/duo` | `DELETE /two-factor/duo` | +| Duo (org) | `POST /organizations/{id}/two-factor/get-duo` | `PUT /organizations/{id}/two-factor/duo` | `DELETE /organizations/{id}/two-factor/duo` | +| WebAuthn | `POST /two-factor/get-webauthn`, `POST /two-factor/get-webauthn-challenge` | `PUT /two-factor/webauthn` | `DELETE /two-factor/webauthn` (per-credential), `DELETE /two-factor/webauthn/all` (bulk) | +| Email | `POST /two-factor/get-email` | `PUT /two-factor/email` (via `POST /two-factor/send-email` to ship the OTP first) | `DELETE /two-factor/email` | + +The login-time email endpoint `POST /two-factor/send-email-login` is **not** part of this flow. It's anonymous and authenticates the user via master password / SSO session / device-auth-request access code instead of a UV token, because the user hasn't completed login yet and has no authenticated session to mint a UV token from. + +### Why Authenticator uses a different tokenable + +`TwoFactorAuthenticatorUserVerificationTokenable` binds to `UserId + Key`, not `UserId + ProviderType`. The extra binding exists because, for Authenticator, the server **provides** the TOTP shared secret (`Key`) at GET time — either freshly generated for a new enrollment or read from the stored config for a re-fetch — and the user scans it into their app. The PUT body then carries both the user-entered TOTP code and the Key it was computed against. Without the Key baked into the UV token, a compromised client could swap the server-provided Key for an attacker-controlled one between GET and PUT; the PUT-time TOTP check alone wouldn't catch the substitution because the attacker can compute a valid TOTP for any Key they choose. + +No other provider has this shape — Duo creds, YubiKey IDs, WebAuthn attestation, and Email addresses are all client-supplied, so `UserId + ProviderType` is enough. + +The controller validates Authenticator's tokenable inline rather than through `ValidateUserVerificationTokenAsync` because it needs `Key` from the request body to complete the check. diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ce9c6328bc91..6997ed623fa8 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -39,6 +39,14 @@ Task ChangeEmailAsync(User user, string masterPassword, string n Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); + ///

+ /// Removes the entry for from the user's TwoFactorProviders JSON column. + /// The provider's MetaData (TOTP shared secret, Duo client secret, YubiKey IDs, WebAuthn credentials, etc.) + /// is destroyed in the process; the name is historical — this is a hard delete of the provider configuration, + /// not a reversible disable. No-op if is not currently configured. Emits + /// and re-evaluates 2FA-removal policies if no + /// providers remain. + /// Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type); Task DeleteAsync(User user); Task DeleteAsync(User user, string token); @@ -80,7 +88,7 @@ Task UpdatePasswordHash(User user, string newPassword, string GetUserName(ClaimsPrincipal principal); Task SendOTPAsync(User user); Task VerifyOTPAsync(User user, string token); - Task VerifySecretAsync(User user, string secret, bool isSettingMFA = false); + Task VerifySecretAsync(User user, string secret); /// /// We use this method to check if the user has an active new device verification bypass /// diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 446853583f67..80fc7eb74b52 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1096,31 +1096,19 @@ public async Task VerifyOTPAsync(User user, string token) "otp:" + user.Email, token); } - public async Task VerifySecretAsync(User user, string secret, bool isSettingMFA = false) + public async Task VerifySecretAsync(User user, string secret) { - bool isVerified; if (user.HasMasterPassword()) { // If the user has a master password the secret is most likely going to be a hash // of their password, but in certain scenarios, like when the user has logged into their // device without a password (trusted device encryption) but the account // does still have a password we will allow the use of OTP. - isVerified = await CheckPasswordAsync(user, secret) || - await VerifyOTPAsync(user, secret); - } - else if (isSettingMFA) - { - // this is temporary to allow users to view their MFA settings without invalidating email TOTP - // Will be removed with PM-9925 - isVerified = true; - } - else - { - // If they don't have a password at all they can only do OTP - isVerified = await VerifyOTPAsync(user, secret); + return await CheckPasswordAsync(user, secret) || await VerifyOTPAsync(user, secret); } - return isVerified; + // If they don't have a password at all they can only do OTP + return await VerifyOTPAsync(user, secret); } public async Task ActiveNewDeviceVerificationException(Guid userId) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index e45b800edb14..a62a2456d01b 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -49,6 +49,7 @@ public virtual string MailTemplateDirectory public virtual bool EnableNewDeviceVerification { get; set; } public virtual bool EnableCloudCommunication { get; set; } = false; public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days + public virtual int TwoFactorUserVerificationTokenLifetimeInMinutes { get; set; } = 30; public virtual int DeviceLastActivityCacheTtlHours { get; set; } = 120; // 5 days public virtual string EventGridKey { get; set; } public virtual bool TestPlayIdTrackingEnabled { get; set; } = false; diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index b7b6f0c8017a..4fce2edca165 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -15,6 +15,7 @@ public interface IGlobalSettings string LicenseCertificatePassword { get; set; } string LicenseCertificatePath { get; set; } int OrganizationInviteExpirationHours { get; set; } + int TwoFactorUserVerificationTokenLifetimeInMinutes { get; set; } int DeviceLastActivityCacheTtlHours { get; set; } bool DisableUserRegistration { get; set; } bool SuppressOnboardingInterstitials { get; set; } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index aa9595b3966b..c5bc0fa8b754 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -258,6 +258,13 @@ public static void AddTokenizers(this IServiceCollection services) TwoFactorAuthenticatorUserVerificationTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton(); + services.AddSingleton>( + serviceProvider => new DataProtectorTokenFactory( + TwoFactorUserVerificationTokenable.ClearTextPrefix, + TwoFactorUserVerificationTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) diff --git a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs index 368166663fbf..a2b080beaf31 100644 --- a/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/TwoFactorControllerTest.cs @@ -1,26 +1,41 @@ using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using Bit.Api.Auth.Models.Request; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Tokens; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using OtpNet; using Xunit; namespace Bit.Api.IntegrationTest.Controllers; public class TwoFactorControllerTest : IClassFixture, IAsyncLifetime { - private const string _key = "JBSWY3DPEHPK3PXP"; + private const string _masterPasswordHash = "master_password_hash"; + private const string _authenticatorKey = "JBSWY3DPEHPK3PXP"; private readonly HttpClient _client; private readonly ApiApplicationFactory _factory; private readonly LoginHelper _loginHelper; private readonly IUserRepository _userRepository; - private readonly IDataProtectorTokenFactory _userVerificationTokenFactory; + private readonly IOrganizationRepository _organizationRepository; + private readonly IDataProtectorTokenFactory _authenticatorTokenFactory; + private readonly IDataProtectorTokenFactory _userVerificationTokenFactory; private string _userEmail = null!; @@ -28,10 +43,19 @@ public TwoFactorControllerTest(ApiApplicationFactory factory) { _factory = factory; _factory.SubstituteService(_ => { }); + _factory.SubstituteService(svc => + svc.ValidateDuoConfiguration(default, default, default).ReturnsForAnyArgs(true)); + _factory.SubstituteService(svc => + svc.CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!).ReturnsForAnyArgs(true)); + _factory.SubstituteService(svc => + svc.DeleteTwoFactorWebAuthnCredentialAsync(default!, default).ReturnsForAnyArgs(true)); + _factory.SubstituteService(_ => { }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); _userRepository = _factory.GetService(); - _userVerificationTokenFactory = _factory.GetService>(); + _organizationRepository = _factory.GetService(); + _authenticatorTokenFactory = _factory.GetService>(); + _userVerificationTokenFactory = _factory.GetService>(); } public async Task InitializeAsync() @@ -47,75 +71,673 @@ public Task DisposeAsync() return Task.CompletedTask; } - /// - /// Verifies the PUT /two-factor/authenticator call site composes its token - /// guard correctly - /// + // --------------------------------------------------------------------- + // Authenticator + // --------------------------------------------------------------------- + [Fact] public async Task PutAuthenticator_ExpiredToken_BadRequest() { var user = await _userRepository.GetByEmailAsync(_userEmail); - Assert.NotNull(user); + var expiredToken = _authenticatorTokenFactory.Protect( + new TwoFactorAuthenticatorUserVerificationTokenable(user!, _authenticatorKey) + { + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var response = await _client.PutAsJsonAsync("/two-factor/authenticator", + new TwoFactorAuthenticatorUpdateRequestModel + { + Token = "123456", + Key = _authenticatorKey, + UserVerificationToken = expiredToken, + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task DeleteAuthenticator_ExpiredToken_BadRequest() + { + var user = await _userRepository.GetByEmailAsync(_userEmail); + var expiredToken = _authenticatorTokenFactory.Protect( + new TwoFactorAuthenticatorUserVerificationTokenable(user!, _authenticatorKey) + { + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/authenticator", + new TwoFactorAuthenticatorDeleteRequestModel + { + Key = _authenticatorKey, + UserVerificationToken = expiredToken, + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); + } - var expiredToken = ProtectExpiredUserVerificationToken(user); + [Fact] + public async Task GetAuthenticator_ValidSecret_ReturnsTokenUsableForDelete() + { + await EnrollUserInAuthenticator(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-authenticator", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var root = await ReadJsonRootAsync(getResponse); + Assert.True(root.GetProperty("enabled").GetBoolean()); + var key = root.GetProperty("key").GetString()!; + var uvToken = root.GetProperty("userVerificationToken").GetString()!; + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/authenticator", + new TwoFactorAuthenticatorDeleteRequestModel + { + Key = key, + UserVerificationToken = uvToken, + }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Authenticator)); + } + + [Fact] + public async Task PutAuthenticator_ValidTokenAndCode_UpdatesProvider() + { + // GET mints a fresh key + UV token; compute the TOTP off that key and PUT. + // AuthenticatorTokenProvider is verify-only (GenerateAsync returns null); compute the + // TOTP with OtpNet, which is the same library the provider validates against. + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-authenticator", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var root = await ReadJsonRootAsync(getResponse); + var key = root.GetProperty("key").GetString()!; + var uvToken = root.GetProperty("userVerificationToken").GetString()!; + var totp = new Totp(Base32Encoding.ToBytes(key)).ComputeTotp(); + + var response = await _client.PutAsJsonAsync("/two-factor/authenticator", + new TwoFactorAuthenticatorUpdateRequestModel + { + Token = totp, + Key = key, + UserVerificationToken = uvToken, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.NotNull(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Authenticator)); + } - var requestModel = new UpdateTwoFactorAuthenticatorRequestModel + [Fact] + public async Task DeleteAuthenticator_BodyTypeMismatch_RespectsUrlRoute() + { + // Any "Type" field sent in the wire body must be ignored — the URL determines the + // provider. Enroll Authenticator and Email, then send a body claiming Type=Email + // to /authenticator and assert only Authenticator is touched. + await EnrollUserInAuthenticator(); + await EnrollUserInEmail(); + + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + var authToken = _authenticatorTokenFactory.Protect( + new TwoFactorAuthenticatorUserVerificationTokenable(user, _authenticatorKey)); + + var body = new JsonObject { - Token = "123456", - Key = _key, - UserVerificationToken = expiredToken, - MasterPasswordHash = "master_password_hash", + ["Type"] = (int)TwoFactorProviderType.Email, + ["Key"] = _authenticatorKey, + ["UserVerificationToken"] = authToken, }; - using var message = new HttpRequestMessage(HttpMethod.Put, "/two-factor/authenticator"); - message.Content = JsonContent.Create(requestModel); - var response = await _client.SendAsync(message); + var response = await SendRawJsonAsync(HttpMethod.Delete, "/two-factor/authenticator", body.ToJsonString()); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var refreshed = (await _userRepository.GetByEmailAsync(_userEmail))!; + Assert.Null(refreshed.GetTwoFactorProvider(TwoFactorProviderType.Authenticator)); + Assert.NotNull(refreshed.GetTwoFactorProvider(TwoFactorProviderType.Email)); + } + + // --------------------------------------------------------------------- + // YubiKey + // --------------------------------------------------------------------- + + [Fact] + public async Task GetYubiKey_ValidSecret_ReturnsTokenUsableForDelete() + { + await EnrollUserInYubiKey(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-yubikey", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/yubikey", + new TwoFactorYubiKeyDeleteRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.YubiKey)); + } + + [Fact] + public async Task PutYubiKey_ValidTokenAndPremium_UpdatesProvider() + { + await GrantPremium(); + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-yubikey", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PutAsJsonAsync("/two-factor/yubikey", + new TwoFactorYubiKeyUpdateRequestModel + { + Key1 = "ccccccccccbe", + Nfc = true, + UserVerificationToken = uvToken, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.NotNull(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.YubiKey)); + } + + // --------------------------------------------------------------------- + // Duo (personal) + // --------------------------------------------------------------------- + + [Fact] + public async Task GetDuo_ValidSecret_ReturnsTokenUsableForDelete() + { + await EnrollUserInDuo(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-duo", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/duo", + new TwoFactorDuoDeleteRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Duo)); + } + + [Fact] + public async Task PutDuo_ValidTokenAndPremium_UpdatesProvider() + { + await GrantPremium(); + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-duo", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PutAsJsonAsync("/two-factor/duo", + new TwoFactorDuoUpdateRequestModel + { + ClientId = new string('a', 20), + ClientSecret = new string('b', 40), + Host = "api-test.duosecurity.com", + UserVerificationToken = uvToken, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.NotNull(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Duo)); + } + + // --------------------------------------------------------------------- + // Organization Duo + // --------------------------------------------------------------------- + + [Fact] + public async Task GetOrganizationDuo_ValidSecret_ReturnsTokenUsableForDelete() + { + var (org, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, ownerEmail: _userEmail, passwordManagerSeats: 1); + // Refresh the user's JWT so org-membership claims are present for ManagePolicies. + await _loginHelper.LoginAsync(_userEmail); + await EnrollOrganizationInDuo(org.Id); + + var getResponse = await _client.PostAsJsonAsync( + $"/organizations/{org.Id}/two-factor/get-duo", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, + $"/organizations/{org.Id}/two-factor/duo", + new TwoFactorOrganizationDuoDeleteRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshedOrg = await _organizationRepository.GetByIdAsync(org.Id); + Assert.Null(refreshedOrg!.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo)); + } + + [Fact] + public async Task PutOrganizationDuo_ValidToken_UpdatesProvider() + { + var (org, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, ownerEmail: _userEmail, passwordManagerSeats: 1); + await _loginHelper.LoginAsync(_userEmail); + var getResponse = await _client.PostAsJsonAsync( + $"/organizations/{org.Id}/two-factor/get-duo", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PutAsJsonAsync( + $"/organizations/{org.Id}/two-factor/duo", + new TwoFactorDuoUpdateRequestModel + { + ClientId = new string('a', 20), + ClientSecret = new string('b', 40), + Host = "api-test.duosecurity.com", + UserVerificationToken = uvToken, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshedOrg = await _organizationRepository.GetByIdAsync(org.Id); + Assert.NotNull(refreshedOrg!.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo)); + } + + // --------------------------------------------------------------------- + // WebAuthn + // --------------------------------------------------------------------- + + [Fact] + public async Task GetWebAuthn_ValidSecret_ReturnsTokenUsableForDelete() + { + await EnrollUserInWebAuthn(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + // DeleteWebAuthn removes a specific credential by Id, not the whole provider; the + // delete command is substituted to return true, so this proves UV-token wiring + // and route mounting end-to-end. + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn", + new + { + Id = 0, + UserVerificationToken = uvToken, + }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + } + + [Fact] + public async Task GetWebAuthnChallenge_ValidSecret_ReturnsTokenUsableForPut() + { + var challengeResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn-challenge", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, challengeResponse.StatusCode); + var root = await ReadJsonRootAsync(challengeResponse); + Assert.Equal(JsonValueKind.Object, root.GetProperty("options").ValueKind); + var uvToken = root.GetProperty("userVerificationToken").GetString()!; + Assert.False(string.IsNullOrEmpty(uvToken)); + + var putResponse = await _client.PutAsJsonAsync("/two-factor/webauthn", + new + { + Id = 0, + Name = "TestKey", + DeviceResponse = new { }, + UserVerificationToken = uvToken, + }); + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + } + + [Fact] + public async Task DeleteWebAuthnAll_ValidToken_RemovesProvider() + { + await EnrollUserInWebAuthn(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", + new TwoFactorWebAuthnDeleteAllRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn)); + } + + [Fact] + public async Task DeleteWebAuthnAll_SingleCredentialEnrollment_RemovesProvider() + { + // The per-credential DELETE /two-factor/webauthn refuses the last registered credential + // (lockout prevention in DeleteTwoFactorWebAuthnCredentialCommand). The bulk-disable + // endpoint is the only path that handles "user has exactly one WebAuthn credential and + // wants to disable WebAuthn entirely" correctly. + await EnrollUserInWebAuthn(); // single-credential enrollment + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-webauthn", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", + new TwoFactorWebAuthnDeleteAllRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn)); + } + + [Fact] + public async Task DeleteWebAuthnAll_ExpiredToken_BadRequest() + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + var expiredToken = _userVerificationTokenFactory.Protect(new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", + new TwoFactorWebAuthnDeleteAllRequestModel { UserVerificationToken = expiredToken }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("User verification failed.", content); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); + } - // User must not have picked up an authenticator provider entry. - var unchanged = await _userRepository.GetByEmailAsync(_userEmail); - Assert.NotNull(unchanged); - Assert.Null(unchanged.GetTwoFactorProvider(TwoFactorProviderType.Authenticator)); + [Fact] + public async Task DeleteWebAuthnAll_CrossProviderToken_BadRequest() + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + // Token bound to Duo replayed against the WebAuthn-all DELETE endpoint. + var duoToken = ProtectUserVerificationToken(user, TwoFactorProviderType.Duo); + + var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/webauthn/all", + new TwoFactorWebAuthnDeleteAllRequestModel { UserVerificationToken = duoToken }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); } - /// - /// Verifies the DELETE /two-factor/authenticator call site composes its - /// token guard correctly - /// + // --------------------------------------------------------------------- + // Email + // --------------------------------------------------------------------- + [Fact] - public async Task DisableAuthenticator_ExpiredToken_BadRequest() + public async Task GetEmail_ValidSecret_ReturnsTokenUsableForDelete() { - var user = await _userRepository.GetByEmailAsync(_userEmail); - Assert.NotNull(user); + await EnrollUserInEmail(); + + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (enabled, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + Assert.True(enabled); + Assert.False(string.IsNullOrEmpty(uvToken)); + + var disableResponse = await SendJsonAsync(HttpMethod.Delete, "/two-factor/email", + new TwoFactorEmailDeleteRequestModel { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + var refreshed = await _userRepository.GetByEmailAsync(_userEmail); + Assert.Null(refreshed!.GetTwoFactorProvider(TwoFactorProviderType.Email)); + } + + [Fact] + public async Task PutEmail_ValidTokenAndCode_UpdatesProvider() + { + // Full enrollment chain: GET mints the UV token, SendEmail validates it and triggers + // the email service, and PUT consumes the UV token + OTP. + // + // The OTP itself cannot be intercepted from the substituted email service — production + // computes it inside SendTwoFactorSetupEmailAsync (from SecurityStamp + Email metadata + // + time window) and ships it out of band via the user's inbox. SendEmail also doesn't + // persist the Email metadata it temporarily attaches via model.ToUser, so a fresh fetch + // post-SendEmail has no metadata for OTP generation. + // + // The test reproduces the OTP locally by applying the same in-memory mutation SendEmail + // applied (Email metadata = _userEmail) and asking UserManager to generate a token. The + // resulting OTP matches what PutEmail will validate because PutEmail's server-side flow + // applies the same mutation against the same SecurityStamp before checking. + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); - var expiredToken = ProtectExpiredUserVerificationToken(user); + var sendResponse = await _client.PostAsJsonAsync("/two-factor/send-email", + new + { + Email = _userEmail, + UserVerificationToken = uvToken, + }); + Assert.Equal(HttpStatusCode.OK, sendResponse.StatusCode); - var requestModel = new TwoFactorAuthenticatorDisableRequestModel + var userManager = _factory.GetService>(); + var userForOtp = (await _userRepository.GetByEmailAsync(_userEmail))!; + userForOtp.SetTwoFactorProviders(new Dictionary { - Type = TwoFactorProviderType.Authenticator, - Key = _key, - UserVerificationToken = expiredToken, - MasterPasswordHash = "master_password_hash", - }; + [TwoFactorProviderType.Email] = new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = _userEmail.ToLowerInvariant() }, + Enabled = true, + }, + }); + var emailOtp = await userManager.GenerateTwoFactorTokenAsync(userForOtp, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)); + + var response = await _client.PutAsJsonAsync("/two-factor/email", + new TwoFactorEmailUpdateRequestModel + { + Email = _userEmail, + Token = emailOtp, + UserVerificationToken = uvToken, + }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var afterPut = await _userRepository.GetByEmailAsync(_userEmail); + Assert.NotNull(afterPut!.GetTwoFactorProvider(TwoFactorProviderType.Email)); + } + + [Fact] + public async Task SendEmail_ValidToken_InvokesEmailService() + { + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + var emailService = _factory.GetService(); + + var response = await _client.PostAsJsonAsync("/two-factor/send-email", + new + { + Email = _userEmail, + UserVerificationToken = uvToken, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await emailService.Received().SendTwoFactorSetupEmailAsync(Arg.Any()); + } + + [Fact] + public async Task SendEmail_MissingUserVerificationToken_BadRequest() + { + var response = await _client.PostAsJsonAsync("/two-factor/send-email", + new { Email = _userEmail }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task SendEmail_MissingEmail_BadRequest() + { + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); - using var message = new HttpRequestMessage(HttpMethod.Delete, "/two-factor/authenticator"); - message.Content = JsonContent.Create(requestModel); - var response = await _client.SendAsync(message); + var response = await _client.PostAsJsonAsync("/two-factor/send-email", + new { UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Fact] + public async Task PutEmail_MissingUserVerificationToken_BadRequest() + { + var response = await _client.PutAsJsonAsync("/two-factor/email", + new { Email = _userEmail, Token = "123456" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("User verification failed.", content); } - private string ProtectExpiredUserVerificationToken(User user) + [Fact] + public async Task PutEmail_MissingToken_BadRequest() { - var tokenable = new TwoFactorAuthenticatorUserVerificationTokenable(user, _key) + var getResponse = await _client.PostAsJsonAsync("/two-factor/get-email", + new { MasterPasswordHash = _masterPasswordHash }); + var (_, uvToken) = await ReadEnabledAndUserVerificationTokenAsync(getResponse); + + var response = await _client.PutAsJsonAsync("/two-factor/email", + new { Email = _userEmail, UserVerificationToken = uvToken }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task SendEmailLogin_ValidMasterPassword_InvokesEmailService() + { + var emailService = _factory.GetService(); + + var response = await _client.PostAsJsonAsync("/two-factor/send-email-login", + new + { + Email = _userEmail, + MasterPasswordHash = _masterPasswordHash, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await emailService.Received().SendTwoFactorEmailAsync(Arg.Any()); + } + + [Fact] + public async Task SendEmailLogin_NoCredentials_BadRequest() + { + // Body carries only Email — no MasterPasswordHash, OTP, AuthRequestAccessCode, + // or SsoEmail2FaSessionToken. The model's Validate must reject before the controller runs. + var response = await _client.PostAsJsonAsync("/two-factor/send-email-login", + new { Email = _userEmail }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + // --------------------------------------------------------------------- + // Cross-cutting: provider-type binding + legacy endpoint removal + // --------------------------------------------------------------------- + + [Fact] + public async Task DeleteYubiKey_CrossProviderToken_BadRequest() + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + // Token bound to Duo replayed against the YubiKey DELETE endpoint. + var duoToken = ProtectUserVerificationToken(user, TwoFactorProviderType.Duo); + + var response = await SendJsonAsync(HttpMethod.Delete, "/two-factor/yubikey", + new TwoFactorYubiKeyDeleteRequestModel { UserVerificationToken = duoToken }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("User verification failed.", await response.Content.ReadAsStringAsync()); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private string ProtectUserVerificationToken(User user, TwoFactorProviderType providerType) => + _userVerificationTokenFactory.Protect(new TwoFactorUserVerificationTokenable { - ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + UserId = user.Id, + ProviderType = providerType, + ExpirationDate = DateTime.UtcNow.AddMinutes(30), + }); + + private async Task EnrollUserInAuthenticator() => + await SetUserTwoFactorProvidersJson( + $"{{\"0\":{{\"Enabled\":true,\"MetaData\":{{\"Key\":\"{_authenticatorKey}\"}}}}}}"); + + private async Task EnrollUserInYubiKey() => + await SetUserTwoFactorProvidersJson( + "{\"3\":{\"Enabled\":true,\"MetaData\":{\"Key1\":\"ccccccccccbe\",\"Nfc\":true}}}"); + + private async Task EnrollUserInDuo() => + await SetUserTwoFactorProvidersJson( + "{\"2\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"" + new string('s', 40) + + "\",\"ClientId\":\"" + new string('c', 20) + "\",\"Host\":\"api-test.duosecurity.com\"}}}"); + + private async Task EnrollUserInEmail() => + await SetUserTwoFactorProvidersJson( + "{\"1\":{\"Enabled\":true,\"MetaData\":{\"Email\":\"" + _userEmail + "\"}}}"); + + private async Task EnrollUserInWebAuthn() => + await SetUserTwoFactorProvidersJson( + "{\"7\":{\"Enabled\":true,\"MetaData\":{\"Key0\":{\"Name\":\"TestKey\",\"Descriptor\":{\"Id\":\"AAAA\",\"Type\":0,\"Transports\":null},\"PublicKey\":\"AAAA\",\"UserHandle\":\"AAAA\",\"SignatureCounter\":0,\"RegDate\":\"2024-01-01T00:00:00\",\"Migrated\":false,\"AaGuid\":\"00000000-0000-0000-0000-000000000000\"}}}}"); + + private async Task EnrollOrganizationInDuo(Guid organizationId) + { + var org = (await _organizationRepository.GetByIdAsync(organizationId))!; + org.TwoFactorProviders = + "{\"6\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"" + new string('s', 40) + + "\",\"ClientId\":\"" + new string('c', 20) + "\",\"Host\":\"api-test.duosecurity.com\"}}}"; + await _organizationRepository.UpsertAsync(org); + } + + private async Task SetUserTwoFactorProvidersJson(string providersJson) + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + user.TwoFactorProviders = providersJson; + await _userRepository.UpsertAsync(user); + } + + private async Task GrantPremium() + { + var user = (await _userRepository.GetByEmailAsync(_userEmail))!; + user.Premium = true; + await _userRepository.UpsertAsync(user); + } + + // Response models (e.g., TwoFactorWebAuthnResponseModel, TwoFactorAuthenticatorResponseModel) + // declare a parameterized constructor that System.Text.Json cannot map for deserialization. + // Read the fields the tests care about structurally instead. + private static async Task<(bool Enabled, string UserVerificationToken)> ReadEnabledAndUserVerificationTokenAsync( + HttpResponseMessage response) + { + var root = await ReadJsonRootAsync(response); + return ( + root.GetProperty("enabled").GetBoolean(), + root.GetProperty("userVerificationToken").GetString() ?? string.Empty); + } + + private static async Task ReadJsonRootAsync(HttpResponseMessage response) + { + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.Clone(); + } + + private Task SendJsonAsync(HttpMethod method, string url, T body) => + SendRawJsonAsync(method, url, JsonSerializer.Serialize(body)); + + private async Task SendRawJsonAsync(HttpMethod method, string url, string json) + { + using var message = new HttpRequestMessage(method, url) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; - return _userVerificationTokenFactory.Protect(tokenable); + return await _client.SendAsync(message); } } diff --git a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs index 6c8c583d20ff..f609f82409f5 100644 --- a/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/TwoFactorControllerTests.cs @@ -6,6 +6,8 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -25,8 +27,12 @@ namespace Bit.Api.Test.Auth.Controllers; [SutProviderCustomize] public class TwoFactorControllerTests { + // --------------------------------------------------------------------- + // Entry-secret verification (ValidateUserBySecretAsync helper) + // --------------------------------------------------------------------- + [Theory, BitAutoData] - public async Task CheckAsync_UserNull_ThrowsUnauthorizedException(SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task ValidateUserBySecretAsync_UserNull_ThrowsUnauthorizedException(SecretVerificationRequestModel request, SutProvider sutProvider) { // Arrange sutProvider.GetDependency() @@ -41,7 +47,7 @@ public async Task CheckAsync_UserNull_ThrowsUnauthorizedException(SecretVerifica } [Theory, BitAutoData] - public async Task CheckAsync_BadSecret_ThrowsBadRequestException(User user, SecretVerificationRequestModel request, SutProvider sutProvider) + public async Task ValidateUserBySecretAsync_BadSecret_ThrowsBadRequestException(User user, SecretVerificationRequestModel request, SutProvider sutProvider) { // Arrange sutProvider.GetDependency() @@ -64,201 +70,14 @@ public async Task CheckAsync_BadSecret_ThrowsBadRequestException(User user, Secr } } - [Theory, BitAutoData] - public async Task CheckAsync_CannotAccessPremium_ThrowsBadRequestException(User user, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .GetUserByPrincipalAsync(default) - .ReturnsForAnyArgs(user); - - sutProvider.GetDependency() - .VerifySecretAsync(default, default) - .ReturnsForAnyArgs(true); - - sutProvider.GetDependency() - .CanAccessPremium(default) - .ReturnsForAnyArgs(false); - - // Act - try - { - await sutProvider.Sut.GetDuo(request); - } - catch (BadRequestException e) - { - // Assert - Assert.Equal("Premium status is required.", e.Message); - } - } - - [Theory, BitAutoData] - public async Task GetDuo_Success(User user, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); - - // Act - var result = await sutProvider.Sut.GetDuo(request); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - } - - [Theory, BitAutoData] - public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) - { - // Arrange - SetupCheckAsyncToPass(sutProvider, user); - sutProvider.GetDependency() - .ValidateDuoConfiguration(default, default, default) - .Returns(false); - - // Act - try - { - await sutProvider.Sut.PutDuo(request); - } - catch (BadRequestException e) - { - // Assert - Assert.Equal("Duo configuration settings are not valid. Please re-check the Duo Admin panel.", e.Message); - } - } - - [Theory, BitAutoData] - public async Task PutDuo_Success(User user, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) - { - // Arrange - user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); - - sutProvider.GetDependency() - .ValidateDuoConfiguration(default, default, default) - .ReturnsForAnyArgs(true); - - // Act - var result = await sutProvider.Sut.PutDuo(request); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(user.TwoFactorProviders, request.ToUser(user).TwoFactorProviders); - } - - [Theory, BitAutoData] - public async Task CheckOrganizationAsync_ManagePolicies_ThrowsNotFoundException( - User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(false); - - // Act - var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); - - // Assert - await Assert.ThrowsAsync(result); - } - - [Theory, BitAutoData] - public async Task CheckOrganizationAsync_GetByIdAsync_ThrowsNotFoundException( - User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); - - sutProvider.GetDependency() - .ManagePolicies(default) - .ReturnsForAnyArgs(true); - - sutProvider.GetDependency() - .GetByIdAsync(default) - .ReturnsForAnyArgs(null as Organization); - - // Act - var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); - - // Assert - await Assert.ThrowsAsync(result); - } - - [Theory, BitAutoData] - public async Task GetOrganizationDuo_Success( - User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) - { - // Arrange - organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); - SetupCheckAsyncToPass(sutProvider, user); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); - - // Act - var result = await sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - } - - [Theory, BitAutoData] - public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestException( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) - { - // Arrange - SetupCheckAsyncToPass(sutProvider, user); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); - - sutProvider.GetDependency() - .ValidateDuoConfiguration(default, default, default) - .ReturnsForAnyArgs(false); - - // Act - try - { - await sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); - } - catch (BadRequestException e) - { - // Assert - Assert.Equal("Duo configuration settings are not valid. Please re-check the Duo Admin panel.", e.Message); - } - } - - [Theory, BitAutoData] - public async Task PutOrganizationDuo_Success( - User user, Organization organization, UpdateTwoFactorDuoRequestModel request, SutProvider sutProvider) - { - // Arrange - SetupCheckAsyncToPass(sutProvider, user); - SetupCheckOrganizationAsyncToPass(sutProvider, organization); - organization.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); - - sutProvider.GetDependency() - .ValidateDuoConfiguration(default, default, default) - .ReturnsForAnyArgs(true); - - // Act - var result = - await sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - Assert.Equal(organization.TwoFactorProviders, request.ToOrganization(organization).TwoFactorProviders); - } - + // --------------------------------------------------------------------- + // Authenticator + // --------------------------------------------------------------------- [Theory, BitAutoData] public async Task PutAuthenticator_ExpiredToken_ThrowsBadRequest( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorAuthenticatorUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -274,7 +93,7 @@ public async Task PutAuthenticator_ExpiredToken_ThrowsBadRequest( [Theory, BitAutoData] public async Task PutAuthenticator_TryUnprotectFails_ThrowsBadRequest( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorAuthenticatorUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -290,7 +109,7 @@ public async Task PutAuthenticator_TryUnprotectFails_ThrowsBadRequest( public async Task PutAuthenticator_InvalidTokenData_ThrowsBadRequest( User user, User otherUser, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorAuthenticatorUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -303,7 +122,7 @@ public async Task PutAuthenticator_InvalidTokenData_ThrowsBadRequest( [Theory, BitAutoData] public async Task PutAuthenticator_ValidToken_ReturnsResponse( User user, - UpdateTwoFactorAuthenticatorRequestModel model, + TwoFactorAuthenticatorUpdateRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -331,9 +150,9 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task DisableAuthenticator_ExpiredToken_ThrowsBadRequest( + public async Task DeleteAuthenticator_ExpiredToken_ThrowsBadRequest( User user, - TwoFactorAuthenticatorDisableRequestModel model, + TwoFactorAuthenticatorDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -342,14 +161,14 @@ public async Task DisableAuthenticator_ExpiredToken_ThrowsBadRequest( ExpirationDate = DateTime.UtcNow.AddMinutes(-1) }); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task DisableAuthenticator_TryUnprotectFails_ThrowsBadRequest( + public async Task DeleteAuthenticator_TryUnprotectFails_ThrowsBadRequest( User user, - TwoFactorAuthenticatorDisableRequestModel model, + TwoFactorAuthenticatorDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); @@ -357,37 +176,36 @@ public async Task DisableAuthenticator_TryUnprotectFails_ThrowsBadRequest( .TryUnprotect(model.UserVerificationToken, out Arg.Any()) .Returns(false); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task DisableAuthenticator_InvalidTokenData_ThrowsBadRequest( + public async Task DeleteAuthenticator_InvalidTokenData_ThrowsBadRequest( User user, User otherUser, - TwoFactorAuthenticatorDisableRequestModel model, + TwoFactorAuthenticatorDeleteRequestModel model, SutProvider sutProvider) { SetupGetUserByPrincipalAsync(sutProvider, user); SetupAuthenticatorTokenFactoryToUnprotectInto(sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(otherUser, "different-key")); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DisableAuthenticator(model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAuthenticator(model)); AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); } [Theory, BitAutoData] - public async Task DisableAuthenticator_ValidToken_ReturnsResponse( + public async Task DeleteAuthenticator_ValidToken_ReturnsResponse( User user, - TwoFactorAuthenticatorDisableRequestModel model, + TwoFactorAuthenticatorDeleteRequestModel model, SutProvider sutProvider) { - model.Type = TwoFactorProviderType.Authenticator; SetupGetUserByPrincipalAsync(sutProvider, user); SetupAuthenticatorTokenFactoryToUnprotectInto( sutProvider, new TwoFactorAuthenticatorUserVerificationTokenable(user, model.Key)); - var response = await sutProvider.Sut.DisableAuthenticator(model); + var response = await sutProvider.Sut.DeleteAuthenticator(model); Assert.IsType(response); await sutProvider.GetDependency() @@ -395,56 +213,1419 @@ await sutProvider.GetDependency() .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Authenticator); } - private static void SetupGetUserByPrincipalAsync(SutProvider sutProvider, User user) + // --------------------------------------------------------------------- + // YubiKey + // --------------------------------------------------------------------- + + [Theory, BitAutoData] + public async Task GetYubiKey_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken( + User user, SecretVerificationRequestModel request, SutProvider sutProvider) { - // PutAuthenticator calls model.ToUser(user) which reads user.TwoFactorProviders - // as JSON. BitAutoData populates that with a random non-JSON string. Clear it so - // the request handler doesn't fail before the token validation we want to test. - user.TwoFactorProviders = null; + // Mirror of GetDuo_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken for YubiKey. + SetupGetUserByPrincipalAsync(sutProvider, user); + user.TwoFactorProviders = GetUserTwoFactorYubiKeyProvidersJson(); + sutProvider.GetDependency() + .VerifySecretAsync(default, default) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(false); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-yubikey-token"); + + var result = await sutProvider.Sut.GetYubiKey(request); + Assert.True(result.Enabled); + Assert.Equal("ccccccccccbe", result.Key1); + Assert.True(result.Nfc); + Assert.Equal("protected-yubikey-token", result.UserVerificationToken); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CanAccessPremium(default); + } + + [Theory, BitAutoData] + public async Task PutYubiKey_ValidToken_ReturnsResponse( + User user, + TwoFactorYubiKeyUpdateRequestModel model, + SutProvider sutProvider) + { + // Null TwoFactorProviders so the response constructor doesn't choke on AutoFixture junk. + user.TwoFactorProviders = null; + // Null all keys so ValidateYubiKeyAsync skips its UserManager round-trips. + model.Key1 = model.Key2 = model.Key3 = model.Key4 = model.Key5 = null; + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); sutProvider.GetDependency() - .GetUserByPrincipalAsync(default) - .ReturnsForAnyArgs(user); + .CanAccessPremium(default) + .ReturnsForAnyArgs(true); + + var response = await sutProvider.Sut.PutYubiKey(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); } - private static void SetupAuthenticatorTokenFactoryToUnprotectInto( - SutProvider sutProvider, - TwoFactorAuthenticatorUserVerificationTokenable tokenable) + [Theory, BitAutoData] + public async Task PutYubiKey_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorYubiKeyUpdateRequestModel model, + SutProvider sutProvider) { - sutProvider.GetDependency>() - .TryUnprotect(Arg.Any(), out Arg.Any()) - .Returns(callInfo => - { - callInfo[1] = tokenable; - return true; - }); + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); } - private static void AssertModelStateContains(BadRequestException exception, string key, string expectedMessage) + [Theory, BitAutoData] + public async Task PutYubiKey_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorYubiKeyUpdateRequestModel model, + SutProvider sutProvider) { - Assert.NotNull(exception.ModelState); - Assert.True(exception.ModelState.ContainsKey(key), $"Expected ModelState to contain key '{key}'."); - Assert.Contains(exception.ModelState[key]!.Errors, e => e.ErrorMessage == expectedMessage); + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); } - private string GetUserTwoFactorDuoProvidersJson() + [Theory, BitAutoData] + public async Task PutYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorYubiKeyUpdateRequestModel model, + SutProvider sutProvider) { - return - "{\"2\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"secretClientSecret\",\"ClientId\":\"clientId\",\"Host\":\"example.com\"}}}"; + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorYubiKeyUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteYubiKey_ValidToken_DeletesProvider( + User user, + TwoFactorYubiKeyDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var response = await sutProvider.Sut.DeleteYubiKey(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.YubiKey); + } + + [Theory, BitAutoData] + public async Task DeleteYubiKey_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorYubiKeyDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteYubiKey_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorYubiKeyDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteYubiKey_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorYubiKeyDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteYubiKey_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorYubiKeyDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteYubiKey(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + // --------------------------------------------------------------------- + // Duo (personal) + // --------------------------------------------------------------------- + + [Theory, BitAutoData] + public async Task GetDuo_Success(User user, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // Arrange + user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); + SetupValidateUserBySecretToPass(sutProvider, user); + + // Act + var result = await sutProvider.Sut.GetDuo(request); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetDuo_NonPremiumUserWithExistingConfig_ReturnsConfigAndToken( + User user, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // A lapsed-premium user (enrolled in Duo while premium, later lost premium) must be + // able to read their own previously-configured provider and receive a UV token so the + // standard GET → DELETE flow lets them disable it. + SetupGetUserByPrincipalAsync(sutProvider, user); + user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); + sutProvider.GetDependency() + .VerifySecretAsync(default, default) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(false); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-duo-token"); + + var result = await sutProvider.Sut.GetDuo(request); + + Assert.True(result.Enabled); + Assert.Equal("example.com", result.Host); + Assert.Equal("clientId", result.ClientId); + // ClientSecret is masked server-side per PM-9826; non-premium users get the same mask. + Assert.StartsWith("secret", result.ClientSecret); + Assert.Contains("*", result.ClientSecret); + Assert.Equal("protected-duo-token", result.UserVerificationToken); + // The read path no longer consults premium. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CanAccessPremium(default); + } + + [Theory, BitAutoData] + public async Task PutDuo_CannotAccessPremium_ThrowsBadRequestException(User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(false); + + // Act + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + + // Assert + Assert.Equal("Premium status is required.", exception.Message); + } + + [Theory, BitAutoData] + public async Task PutDuo_InvalidConfiguration_ThrowsBadRequestException(User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .ValidateDuoConfiguration(default, default, default) + .Returns(false); + + // Act + try + { + await sutProvider.Sut.PutDuo(request); + } + catch (BadRequestException e) + { + // Assert + Assert.Equal("Duo configuration settings are not valid. Please re-check the Duo Admin panel.", e.Message); + } + } + + [Theory, BitAutoData] + public async Task PutDuo_Success(User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + user.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + sutProvider.GetDependency() + .CanAccessPremium(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .ValidateDuoConfiguration(default, default, default) + .ReturnsForAnyArgs(true); + + // Act + var result = await sutProvider.Sut.PutDuo(request); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal(user.TwoFactorProviders, request.ToUser(user).TwoFactorProviders); + } + + [Theory, BitAutoData] + public async Task PutDuo_ExpiredToken_ThrowsBadRequest( + User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + } + + [Theory, BitAutoData] + public async Task PutDuo_TryUnprotectFails_ThrowsBadRequest( + User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(request.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + } + + [Theory, BitAutoData] + public async Task PutDuo_WrongUserId_ThrowsBadRequest( + User user, User otherUser, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, + ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + } + + [Theory, BitAutoData] + public async Task PutDuo_WrongProviderType_ThrowsBadRequest( + User user, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, + ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutDuo(request)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + } + + [Theory, BitAutoData] + public async Task DeleteDuo_ValidToken_DeletesProvider( + User user, + TwoFactorDuoDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var response = await sutProvider.Sut.DeleteDuo(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Duo); + } + + [Theory, BitAutoData] + public async Task DeleteDuo_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorDuoDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteDuo(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteDuo_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorDuoDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteDuo(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteDuo_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorDuoDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteDuo(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteDuo_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorDuoDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteDuo(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + // --------------------------------------------------------------------- + // Organization Duo + // --------------------------------------------------------------------- + + [Theory, BitAutoData] + public async Task GetOrganizationDuo_Success( + User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // Arrange + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + SetupValidateUserBySecretToPass(sutProvider, user); + SetupOrganizationAccessToPass(sutProvider, organization); + + // Act + var result = await sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // Arrange + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + SetupValidateUserBySecretToPass(sutProvider, user); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(false); + + // Act + var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task GetOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, SecretVerificationRequestModel request, SutProvider sutProvider) + { + // Arrange + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + SetupValidateUserBySecretToPass(sutProvider, user); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + + // Act + var result = () => sutProvider.Sut.GetOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task PutOrganizationDuo_InvalidConfiguration_ThrowsBadRequestException( + User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + SetupOrganizationAccessToPass(sutProvider, organization); + + sutProvider.GetDependency() + .ValidateDuoConfiguration(default, default, default) + .ReturnsForAnyArgs(false); + + // Act + try + { + await sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + } + catch (BadRequestException e) + { + // Assert + Assert.Equal("Duo configuration settings are not valid. Please re-check the Duo Admin panel.", e.Message); + } + } + + [Theory, BitAutoData] + public async Task PutOrganizationDuo_Success( + User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + SetupOrganizationAccessToPass(sutProvider, organization); + organization.TwoFactorProviders = GetUserTwoFactorDuoProvidersJson(); + + sutProvider.GetDependency() + .ValidateDuoConfiguration(default, default, default) + .ReturnsForAnyArgs(true); + + // Act + var result = + await sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal(organization.TwoFactorProviders, request.ToOrganization(organization).TwoFactorProviders); + } + + [Theory, BitAutoData] + public async Task PutOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(false); + + // Act + var result = () => sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task PutOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, TwoFactorDuoUpdateRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + + // Act + var result = () => sutProvider.Sut.PutOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationDuo_ManagePolicies_ThrowsNotFoundException( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(false); + + // Act + var result = () => sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationDuo_GetByIdAsync_ThrowsNotFoundException( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + + sutProvider.GetDependency() + .ManagePolicies(default) + .ReturnsForAnyArgs(true); + + sutProvider.GetDependency() + .GetByIdAsync(default) + .ReturnsForAnyArgs(null as Organization); + + // Act + var result = () => sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); + + // Assert + await Assert.ThrowsAsync(result); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationDuo_Success( + User user, Organization organization, TwoFactorOrganizationDuoDeleteRequestModel request, SutProvider sutProvider) + { + // Arrange + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.OrganizationDuo)); + SetupOrganizationAccessToPass(sutProvider, organization); + organization.TwoFactorProviders = GetOrganizationTwoFactorDuoProvidersJson(); + + // Act + var result = await sutProvider.Sut.DeleteOrganizationDuo(organization.Id.ToString(), request); + + // Assert + Assert.IsType(result); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(organization, TwoFactorProviderType.OrganizationDuo); + } + + // --------------------------------------------------------------------- + // WebAuthn + // --------------------------------------------------------------------- + + [Theory, BitAutoData] + public async Task PutWebAuthn_ValidToken_ReturnsResponse( + User user, + TwoFactorWebAuthnUpdateRequestModel model, + SutProvider sutProvider) + { + user.TwoFactorProviders = null; + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + sutProvider.GetDependency() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!) + .ReturnsForAnyArgs(true); + + var response = await sutProvider.Sut.PutWebAuthn(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .CompleteTwoFactorWebAuthnRegistrationAsync(user, model.Id!.Value, model.Name, model.DeviceResponse); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorWebAuthnUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorWebAuthnUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorWebAuthnUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + } + + [Theory, BitAutoData] + public async Task PutWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorWebAuthnUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_ValidToken_ReturnsResponse( + User user, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + user.TwoFactorProviders = null; + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + sutProvider.GetDependency() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default) + .ReturnsForAnyArgs(true); + + var response = await sutProvider.Sut.DeleteWebAuthn(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DeleteTwoFactorWebAuthnCredentialAsync(user, model.Id!.Value); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthn_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthn(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteTwoFactorWebAuthnCredentialAsync(default!, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthnAll_ValidToken_DeletesProvider( + User user, + TwoFactorWebAuthnDeleteAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.WebAuthn)); + + var response = await sutProvider.Sut.DeleteWebAuthnAll(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthnAll_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthnAll_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthnAll_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorWebAuthnDeleteAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.WebAuthn)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteWebAuthnAll_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorWebAuthnDeleteAllRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Duo)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteWebAuthnAll(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + // --------------------------------------------------------------------- + // Email + // --------------------------------------------------------------------- + + [Theory, BitAutoData] + public async Task GetEmail_Success( + User user, + SecretVerificationRequestModel request, + SutProvider sutProvider) + { + // AutoFixture seeds TwoFactorProviders with random junk; the response constructor + // would try to deserialize it. Null it so the constructor takes the no-providers path. + user.TwoFactorProviders = null; + SetupValidateUserBySecretToPass(sutProvider, user); + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns("protected-email-token"); + + var response = await sutProvider.Sut.GetEmail(request); + + Assert.IsType(response); + Assert.Equal("protected-email-token", response.UserVerificationToken); + } + + [Theory, BitAutoData] + public async Task SendEmail_ValidToken_InvokesEmailService( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + await sutProvider.Sut.SendEmail(model); + + await sutProvider.GetDependency() + .Received(1) + .SendTwoFactorSetupEmailAsync(user); + } + + [Theory, BitAutoData] + public async Task SendEmail_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Email, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendTwoFactorSetupEmailAsync(default); + } + + [Theory, BitAutoData] + public async Task SendEmail_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendTwoFactorSetupEmailAsync(default); + } + + [Theory, BitAutoData] + public async Task SendEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendTwoFactorSetupEmailAsync(default); + } + + [Theory, BitAutoData] + public async Task SendEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorEmailSetupRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SendEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendTwoFactorSetupEmailAsync(default); + } + + [Theory, BitAutoData] + public async Task PutEmail_ValidTokenAndOtp_ReturnsResponse( + User user, + TwoFactorEmailUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + var emailProvider = Substitute.For>(); + emailProvider + .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) + .Returns(true); + sutProvider.GetDependency>() + .RegisterTokenProvider( + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), + emailProvider); + + var response = await sutProvider.Sut.PutEmail(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + } + + [Theory, BitAutoData] + public async Task PutEmail_InvalidOtp_ThrowsBadRequest( + User user, + TwoFactorEmailUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + var emailProvider = Substitute.For>(); + emailProvider + .ValidateAsync("TwoFactor", model.Token, Arg.Any>(), Arg.Any()) + .Returns(false); + sutProvider.GetDependency>() + .RegisterTokenProvider( + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), + emailProvider); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "Token", "Invalid token."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorEmailUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Email, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorEmailUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorEmailUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task PutEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorEmailUpdateRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PutEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteEmail_ValidToken_DeletesProvider( + User user, + TwoFactorEmailDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.Email)); + + var response = await sutProvider.Sut.DeleteEmail(model); + + Assert.IsType(response); + await sutProvider.GetDependency() + .Received(1) + .DisableTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + } + + [Theory, BitAutoData] + public async Task DeleteEmail_ExpiredToken_ThrowsBadRequest( + User user, + TwoFactorEmailDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto(sutProvider, new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Email, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteEmail_TryUnprotectFails_ThrowsBadRequest( + User user, + TwoFactorEmailDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + sutProvider.GetDependency>() + .TryUnprotect(model.UserVerificationToken, out Arg.Any()) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteEmail_TokenBoundToDifferentUser_ThrowsBadRequest( + User user, + User otherUser, + TwoFactorEmailDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(otherUser, TwoFactorProviderType.Email)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + [Theory, BitAutoData] + public async Task DeleteEmail_TokenBoundToDifferentProvider_ThrowsBadRequest( + User user, + TwoFactorEmailDeleteRequestModel model, + SutProvider sutProvider) + { + SetupGetUserByPrincipalAsync(sutProvider, user); + SetupUserVerificationTokenFactoryToUnprotectInto( + sutProvider, ValidUserVerificationTokenableFor(user, TwoFactorProviderType.YubiKey)); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteEmail(model)); + AssertModelStateContains(exception, "UserVerificationToken", "User verification failed."); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DisableTwoFactorProviderAsync(default, default); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static void SetupGetUserByPrincipalAsync(SutProvider sutProvider, User user) + { + // Controller actions that call model.ToUser(user) read user.TwoFactorProviders as JSON. + // BitAutoData populates that with a random non-JSON string; clear it so deserialization + // doesn't fail before the token validation the test wants to exercise. + user.TwoFactorProviders = null; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(default) + .ReturnsForAnyArgs(user); + } + + private static void SetupAuthenticatorTokenFactoryToUnprotectInto( + SutProvider sutProvider, + TwoFactorAuthenticatorUserVerificationTokenable tokenable) + { + sutProvider.GetDependency>() + .TryUnprotect(Arg.Any(), out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = tokenable; + return true; + }); + } + + private static void SetupUserVerificationTokenFactoryToUnprotectInto( + SutProvider sutProvider, + TwoFactorUserVerificationTokenable tokenable) + { + sutProvider.GetDependency>() + .TryUnprotect(Arg.Any(), out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = tokenable; + return true; + }); + } + + private static TwoFactorUserVerificationTokenable ValidUserVerificationTokenableFor( + User user, TwoFactorProviderType providerType) => + new() + { + UserId = user.Id, + ProviderType = providerType, + ExpirationDate = DateTime.UtcNow.AddMinutes(30), + }; + + private static void AssertModelStateContains(BadRequestException exception, string key, string expectedMessage) + { + Assert.NotNull(exception.ModelState); + Assert.True(exception.ModelState.ContainsKey(key), $"Expected ModelState to contain key '{key}'."); + Assert.Contains(exception.ModelState[key]!.Errors, e => e.ErrorMessage == expectedMessage); + } + + private static string GetUserTwoFactorDuoProvidersJson() + { + return + "{\"2\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"secretClientSecret\",\"ClientId\":\"clientId\",\"Host\":\"example.com\"}}}"; + } + + private static string GetUserTwoFactorYubiKeyProvidersJson() + { + return + "{\"3\":{\"Enabled\":true,\"MetaData\":{\"Key1\":\"ccccccccccbe\",\"Key2\":null,\"Key3\":null,\"Key4\":null,\"Key5\":null,\"Nfc\":true}}}"; } - private string GetOrganizationTwoFactorDuoProvidersJson() + private static string GetOrganizationTwoFactorDuoProvidersJson() { return "{\"6\":{\"Enabled\":true,\"MetaData\":{\"ClientSecret\":\"secretClientSecret\",\"ClientId\":\"clientId\",\"Host\":\"example.com\"}}}"; } - /// - /// Sets up the CheckAsync method to pass. - /// - /// uses bit auto data - /// uses bit auto data - private void SetupCheckAsyncToPass(SutProvider sutProvider, User user) + private static void SetupValidateUserBySecretToPass(SutProvider sutProvider, User user) { sutProvider.GetDependency() .GetUserByPrincipalAsync(default) @@ -459,7 +1640,7 @@ private void SetupCheckAsyncToPass(SutProvider sutProvider, .ReturnsForAnyArgs(true); } - private void SetupCheckOrganizationAsyncToPass(SutProvider sutProvider, Organization organization) + private static void SetupOrganizationAccessToPass(SutProvider sutProvider, Organization organization) { sutProvider.GetDependency() .ManagePolicies(default) diff --git a/test/Api.Test/Auth/Models/Request/OrganizationTwoFactorDuoRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/OrganizationTwoFactorDuoRequestModelTests.cs index 361adea536d8..ffa506f27182 100644 --- a/test/Api.Test/Auth/Models/Request/OrganizationTwoFactorDuoRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/OrganizationTwoFactorDuoRequestModelTests.cs @@ -14,7 +14,7 @@ public void ShouldAddOrUpdateTwoFactorProvider_WhenExistingProviderDoesNotExist( { // Arrange var existingOrg = new Organization(); - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { ClientId = "clientId", ClientSecret = "clientSecret", @@ -41,7 +41,7 @@ public void ShouldUpdateTwoFactorProvider_WhenExistingProviderExists() { { TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider() } }); - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { ClientId = "newClientId", ClientSecret = "newClientSecret", diff --git a/test/Api.Test/Auth/Models/Request/TwoFactorDuoRequestModelValidationTests.cs b/test/Api.Test/Auth/Models/Request/TwoFactorDuoRequestModelValidationTests.cs index 295d7cbb5afa..cbad61f12e23 100644 --- a/test/Api.Test/Auth/Models/Request/TwoFactorDuoRequestModelValidationTests.cs +++ b/test/Api.Test/Auth/Models/Request/TwoFactorDuoRequestModelValidationTests.cs @@ -10,7 +10,7 @@ public class TwoFactorDuoRequestModelValidationTests public void ShouldReturnValidationError_WhenHostIsInvalid() { // Arrange - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { Host = "invalidHost", ClientId = "clientId", @@ -30,7 +30,7 @@ public void ShouldReturnValidationError_WhenHostIsInvalid() public void ShouldReturnValidationError_WhenValuesAreInvalid() { // Arrange - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { Host = "api-12345abc.duosecurity.com" }; @@ -48,7 +48,7 @@ public void ShouldReturnValidationError_WhenValuesAreInvalid() public void ShouldReturnSuccess_WhenValuesAreValid() { // Arrange - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { Host = "api-12345abc.duosecurity.com", ClientId = "clientId", diff --git a/test/Api.Test/Auth/Models/Request/UserTwoFactorDuoRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/UserTwoFactorDuoRequestModelTests.cs index 56c9af1e0d7e..170fc284fde1 100644 --- a/test/Api.Test/Auth/Models/Request/UserTwoFactorDuoRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/UserTwoFactorDuoRequestModelTests.cs @@ -13,7 +13,7 @@ public void ShouldAddOrUpdateTwoFactorProvider_WhenExistingProviderDoesNotExist( { // Arrange var existingUser = new User(); - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { ClientId = "clientId", ClientSecret = "clientSecret", @@ -40,7 +40,7 @@ public void ShouldUpdateTwoFactorProvider_WhenExistingProviderExists() { { TwoFactorProviderType.Duo, new TwoFactorProvider() } }); - var model = new UpdateTwoFactorDuoRequestModel + var model = new TwoFactorDuoUpdateRequestModel { ClientId = "newClientId", ClientSecret = "newClientSecret", diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactoryTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactoryTests.cs new file mode 100644 index 000000000000..446a6680d38c --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableFactoryTests.cs @@ -0,0 +1,43 @@ +using AutoFixture.Xunit2; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Settings; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; + +public class TwoFactorUserVerificationTokenableFactoryTests +{ + [Theory, AutoData] + public void CreateToken_BindsUserAndProvider(User user) + { + var globalSettings = Substitute.For(); + globalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes.Returns(30); + var sut = new TwoFactorUserVerificationTokenableFactory(globalSettings); + + var token = sut.CreateToken(user, TwoFactorProviderType.Duo); + + Assert.Equal(user.Id, token.UserId); + Assert.Equal(TwoFactorProviderType.Duo, token.ProviderType); + Assert.True(token.Valid); + } + + [Theory, AutoData] + public void CreateToken_HonorsConfiguredLifetime(User user) + { + var globalSettings = Substitute.For(); + globalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes.Returns(15); + var sut = new TwoFactorUserVerificationTokenableFactory(globalSettings); + + var before = DateTime.UtcNow; + var token = sut.CreateToken(user, TwoFactorProviderType.YubiKey); + var after = DateTime.UtcNow; + + Assert.InRange( + token.ExpirationDate, + before + TimeSpan.FromMinutes(15), + after + TimeSpan.FromMinutes(15)); + } +} diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableTests.cs new file mode 100644 index 000000000000..e2be18d6b258 --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/TwoFactorUserVerificationTokenableTests.cs @@ -0,0 +1,144 @@ +using AutoFixture.Xunit2; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Tokens; +using Xunit; + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; + +public class TwoFactorUserVerificationTokenableTests +{ + [Fact] + public void DefaultConstructor_NoFactory_ProducesInvalidToken() + { + // Locks in the "factory is the only mint path" invariant: a directly-constructed + // tokenable has ExpirationDate == default and fails Valid. + var token = new TwoFactorUserVerificationTokenable(); + + Assert.False(token.Valid); + } + + [Theory, AutoData] + public void Valid_FullyPopulatedNonExpired_ReturnsTrue(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.True(token.Valid); + } + + [Theory, AutoData] + public void Valid_ExpiredToken_ReturnsFalse(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = DateTime.UtcNow.AddMinutes(-1), + }; + + Assert.False(token.Valid); + } + + [Theory, AutoData] + public void Valid_WrongIdentifier_ReturnsFalse(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + Identifier = "not the right identifier", + UserId = user.Id, + ProviderType = TwoFactorProviderType.Email, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.Valid); + } + + [Fact] + public void Valid_DefaultUserId_ReturnsFalse() + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = default, + ProviderType = TwoFactorProviderType.WebAuthn, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.Valid); + } + + [Theory, AutoData] + public void TokenIsValid_MatchingUserAndProvider_ReturnsTrue(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.True(token.TokenIsValid(user, TwoFactorProviderType.YubiKey)); + } + + [Theory, AutoData] + public void TokenIsValid_NullUser_ReturnsFalse(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.TokenIsValid(null!, TwoFactorProviderType.YubiKey)); + } + + [Theory, AutoData] + public void TokenIsValid_WrongUser_ReturnsFalse(User user, User otherUser) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.YubiKey, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.TokenIsValid(otherUser, TwoFactorProviderType.YubiKey)); + } + + [Theory, AutoData] + public void TokenIsValid_WrongProviderType_ReturnsFalse(User user) + { + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = DateTime.UtcNow.AddMinutes(5), + }; + + Assert.False(token.TokenIsValid(user, TwoFactorProviderType.YubiKey)); + } + + [Theory, AutoData] + public void FromToken_SerializedToken_PreservesAllFields(User user) + { + var expectedExpiration = DateTime.UtcNow.AddMinutes(-3); + var token = new TwoFactorUserVerificationTokenable + { + UserId = user.Id, + ProviderType = TwoFactorProviderType.Duo, + ExpirationDate = expectedExpiration, + }; + + var result = Tokenable.FromToken(token.ToToken()); + + Assert.Equal(user.Id, result.UserId); + Assert.Equal(TwoFactorProviderType.Duo, result.ProviderType); + Assert.Equal(expectedExpiration, result.ExpirationDate, precision: TimeSpan.FromMilliseconds(10)); + } +}