Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4631404
PM-38137 - Add TwoFactorUserVerificationTokenable infrastructure
JaredSnider-Bitwarden Jun 19, 2026
168ba73
PM-38137 - Add per-provider 2FA request and response models
JaredSnider-Bitwarden Jun 19, 2026
583efbb
PM-38137 - Refactor TwoFactorController helpers and add per-provider …
JaredSnider-Bitwarden Jun 19, 2026
95c3e88
PM-38137 - Refactor Authenticator 2FA request models to standalone shape
JaredSnider-Bitwarden Jun 19, 2026
1aa95c2
PM-38137 - Simplify VerifySecretAsync
JaredSnider-Bitwarden Jun 19, 2026
3f04a49
PM-38137 - Expand 2FA controller integration test coverage
JaredSnider-Bitwarden Jun 19, 2026
40842fc
PM-38137 - Add bulk-disable endpoint for WebAuthn 2FA provider
JaredSnider-Bitwarden Jun 19, 2026
4d28582
PM-38137 - Expand unit-test coverage for per-provider 2FA disable end…
JaredSnider-Bitwarden Jun 19, 2026
aaf3631
PM-38137 - Rename 2FA controller disable endpoints to delete
JaredSnider-Bitwarden Jun 19, 2026
dd7d50a
PM-38137 - TwoFactorController - add TODO for cleaning up obsolete en…
JaredSnider-Bitwarden Jun 22, 2026
7846bd6
PM-38137 - Tighten WebAuthn 2FA request model validation
JaredSnider-Bitwarden Jun 22, 2026
ec2d477
PM-38137 - Tighten Duo and YubiKey 2FA request model validation
JaredSnider-Bitwarden Jun 22, 2026
ee0798f
PM-38137 - Delete unreferenced TwoFactorRecoveryRequestModel
JaredSnider-Bitwarden Jun 22, 2026
f0991a6
PM-38137 - Split Email 2FA request models by flow
JaredSnider-Bitwarden Jun 22, 2026
9884f1b
PM-38137 - Refine TwoFactorUserVerificationTokenable doc summary
JaredSnider-Bitwarden Jun 22, 2026
6913a65
PM-38137 - Dispose JsonDocument in 2FA integration test helper
JaredSnider-Bitwarden Jun 22, 2026
5c1884f
PM-38137 - Expand unit-test coverage for PutYubiKey, PutWebAuthn, and…
JaredSnider-Bitwarden Jun 22, 2026
aef933f
PM-38137 - Organize 2FA controller unit tests into provider-grouped s…
JaredSnider-Bitwarden Jun 22, 2026
3a92642
PM-38137 - Add 2FA feature-area README with user-verification design
JaredSnider-Bitwarden Jun 22, 2026
15913df
PM-38137 - Rename 2FA request models to TwoFactor<Provider><Verb> shape
JaredSnider-Bitwarden Jun 22, 2026
6112806
PM-38137 - Rename stale CheckAsync/CheckOrganizationAsync test refere…
JaredSnider-Bitwarden Jun 22, 2026
92bbc11
PM-38137 - Tidy 2FA controller test helpers
JaredSnider-Bitwarden Jun 22, 2026
915a5e8
PM-38137 - Reword 2FA request model summaries to describe purpose
JaredSnider-Bitwarden Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 158 additions & 105 deletions src/Api/Auth/Controllers/TwoFactorController.cs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions src/Api/Auth/Models/Request/TwoFactorDuoDeleteRequestModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;

namespace Bit.Api.Auth.Models.Request;

/// <summary>Request model for deleting a user's Duo two-factor configuration.</summary>
public class TwoFactorDuoDeleteRequestModel
{
/// <summary>Token minted by <c>GetDuo</c>; bound to <c>UserId + ProviderType</c>.</summary>
[Required]
public string UserVerificationToken { get; set; } = null!;
}
11 changes: 11 additions & 0 deletions src/Api/Auth/Models/Request/TwoFactorEmailDeleteRequestModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;

namespace Bit.Api.Auth.Models.Request;

/// <summary>Request model for deleting a user's Email two-factor configuration.</summary>
public class TwoFactorEmailDeleteRequestModel
{
/// <summary>Token minted by <c>GetEmail</c>; bound to <c>UserId + ProviderType</c>.</summary>
[Required]
public string UserVerificationToken { get; set; } = null!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;

namespace Bit.Api.Auth.Models.Request;

/// <summary>Request model for deleting an organization's Duo two-factor configuration.</summary>
public class TwoFactorOrganizationDuoDeleteRequestModel
{
/// <summary>Token minted by <c>GetOrganizationDuo</c>; bound to <c>UserId + ProviderType</c>.</summary>
[Required]
public string UserVerificationToken { get; set; } = null!;
}
118 changes: 72 additions & 46 deletions src/Api/Auth/Models/Request/TwoFactorRequestModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,23 @@

namespace Bit.Api.Auth.Models.Request;

public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationRequestModel
/// <summary>Request model for setting up or updating a user's Authenticator (TOTP) two-factor configuration.</summary>
public class TwoFactorAuthenticatorUpdateRequestModel
{
/// <summary>Six-digit TOTP code from the authenticator app, proving the user enrolled <see cref="Key"/>.</summary>
[Required]
[StringLength(50)]
public string Token { get; set; }

/// <summary>TOTP shared secret that the token was minted against; must match the token's bound Key.</summary>
[Required]
[StringLength(50)]
public string Key { get; set; }

/// <summary>Token minted by <c>GetAuthenticator</c>; bound to <c>UserId + Key</c>.</summary>
[Required]
public string UserVerificationToken { get; set; }

public User ToUser(User existingUser)
{
var providers = existingUser.GetTwoFactorProviders();
Expand All @@ -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
Expand All @@ -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)
{
Expand Down Expand Up @@ -110,7 +120,7 @@ public Organization ToOrganization(Organization existingOrg)
return existingOrg;
}

public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var results = new List<ValidationResult>();
if (string.IsNullOrWhiteSpace(ClientId))
Expand All @@ -131,7 +141,7 @@ public override IEnumerable<ValidationResult> Validate(ValidationContext validat
}
}

public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestModel, IValidatableObject
public class TwoFactorYubiKeyUpdateRequestModel : IValidatableObject
{
public string Key1 { get; set; }
public string Key2 { get; set; }
Expand All @@ -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)
{
Expand Down Expand Up @@ -180,7 +192,7 @@ private string FormatKey(string keyValue)
return keyValue.Substring(0, 12);
}

public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(Key1) && string.IsNullOrWhiteSpace(Key2) && string.IsNullOrWhiteSpace(Key3) &&
string.IsNullOrWhiteSpace(Key4) && string.IsNullOrWhiteSpace(Key5))
Expand Down Expand Up @@ -215,7 +227,11 @@ public override IEnumerable<ValidationResult> Validate(ValidationContext validat
}
}

public class TwoFactorEmailRequestModel : SecretVerificationRequestModel
/// <summary>
/// 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.
/// </summary>
public class TwoFactorEmailLoginRequestModel : SecretVerificationRequestModel
{
[Required]
[EmailAddress]
Expand All @@ -224,86 +240,96 @@ 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<TwoFactorProviderType, TwoFactorProvider>();
}
else
{
providers.Remove(TwoFactorProviderType.Email);
}

providers.Add(TwoFactorProviderType.Email, new TwoFactorProvider
{
MetaData = new Dictionary<string, object> { ["Email"] = Email.ToLowerInvariant() },
Enabled = true
});
existingUser.SetTwoFactorProviders(providers);
return existingUser;
}

public override IEnumerable<ValidationResult> 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; }
Comment thread
JaredSnider-Bitwarden marked this conversation as resolved.

public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
public IEnumerable<ValidationResult> 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) });
}
}
}

public class UpdateTwoFactorEmailRequestModel : TwoFactorEmailRequestModel
/// <summary>
/// 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.
/// </summary>
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<TwoFactorProviderType, TwoFactorProvider>();
}
else
{
providers.Remove(TwoFactorProviderType.Email);
}

providers.Add(TwoFactorProviderType.Email, new TwoFactorProvider
{
MetaData = new Dictionary<string, object> { ["Email"] = Email.ToLowerInvariant() },
Enabled = true
});
existingUser.SetTwoFactorProviders(providers);
return existingUser;
}
}

public class TwoFactorRecoveryRequestModel : TwoFactorEmailRequestModel
/// <summary>
/// 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.
/// </summary>
public class TwoFactorEmailUpdateRequestModel : TwoFactorEmailSetupRequestModel
{
[Required]
[StringLength(32)]
public string RecoveryCode { get; set; }
[StringLength(50)]
public string Token { get; set; }
}

public class TwoFactorAuthenticatorDisableRequestModel : TwoFactorProviderRequestModel
/// <summary>Request model for deleting a user's Authenticator (TOTP) two-factor configuration.</summary>
public class TwoFactorAuthenticatorDeleteRequestModel
{
/// <summary>Token minted by <c>GetAuthenticator</c>; bound to <c>UserId + Key</c>.</summary>
[Required]
public string UserVerificationToken { get; set; }

/// <summary>TOTP shared secret that the token was minted against; must match the token's bound Key.</summary>
[Required]
public string Key { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;

namespace Bit.Api.Auth.Models.Request;

/// <summary>Request model for deleting all of a user's WebAuthn two-factor credentials.</summary>
public class TwoFactorWebAuthnDeleteAllRequestModel
{
/// <summary>Token minted by <c>GetWebAuthn</c>; bound to <c>UserId + ProviderType</c>.</summary>
[Required]
public string UserVerificationToken { get; set; } = null!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;

namespace Bit.Api.Auth.Models.Request;

/// <summary>Request model for deleting a user's YubiKey two-factor configuration.</summary>
public class TwoFactorYubiKeyDeleteRequestModel
{
/// <summary>Token minted by <c>GetYubiKey</c>; bound to <c>UserId + ProviderType</c>.</summary>
[Required]
public string UserVerificationToken { get; set; } = null!;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ public TwoFactorEmailResponseModel(User user)

public bool Enabled { get; set; }
public string Email { get; set; }
public string UserVerificationToken { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Bit.Core.Models.Api;
using Fido2NetLib;

namespace Bit.Api.Auth.Models.Response.TwoFactor;

/// <summary>
/// Envelope around <see cref="CredentialCreateOptions"/> that adds the user-verification token.
/// </summary>
public class TwoFactorWebAuthnChallengeResponseModel : ResponseModel
{
public TwoFactorWebAuthnChallengeResponseModel()
: base("twoFactorWebAuthnChallenge")
{
}

/// <summary>FIDO2 registration ceremony options; passed straight to <c>navigator.credentials.create()</c>.</summary>
public CredentialCreateOptions Options { get; set; } = null!;

/// <summary>Token to replay on the subsequent PUT so the user does not have to re-verify.</summary>
public string UserVerificationToken { get; set; } = null!;
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public TwoFactorWebAuthnResponseModel(User user)

public bool Enabled { get; set; }
public IEnumerable<KeyModel> Keys { get; set; }
public string UserVerificationToken { get; set; }

public class KeyModel
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
7 changes: 7 additions & 0 deletions src/Core/AdminConsole/Services/IOrganizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
/// <summary>
/// Removes the entry for <paramref name="type"/> from the organization's <c>TwoFactorProviders</c> JSON column.
/// The provider's <c>MetaData</c> (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
/// <paramref name="type"/> is not currently configured. Throws <see cref="ArgumentException"/> if
/// <paramref name="type"/> is not an organization-scoped provider type.
/// </summary>
Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type);
Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
OrganizationUserInvite invite, string externalId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;

namespace Bit.Core.Auth.Models.Business.Tokenables;

/// <summary>Mints <see cref="TwoFactorUserVerificationTokenable"/> instances with the operator-configured lifetime.</summary>
public interface ITwoFactorUserVerificationTokenableFactory
{
/// <summary>Creates a token bound to the given user and provider, expiring after the configured lifetime.</summary>
TwoFactorUserVerificationTokenable CreateToken(User user, TwoFactorProviderType providerType);
}
Loading
Loading