Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
faecd60
[PM-40673 Part 3] update 2FA remember token revoke call sites
rr-bw Sep 23, 2026
787eeb1
[PM-40673 Part 3] update README and test comment for removed 2FA-enab…
rr-bw Oct 17, 2026
013fa61
[PM-40673 Part 3] run policy enforcement before remember-token revoke…
rr-bw Oct 17, 2026
85b9ac3
[PM-40673 Part 3] clean up test comments (remove plan-style prefixes,…
rr-bw Oct 17, 2026
86a21db
[PM-40673 Part 3] run dotnet format on test imports
rr-bw Oct 17, 2026
217338d
[PM-40673 Part 3] skip remember token issuance for recovery-code logins
rr-bw Oct 19, 2026
4a53121
[PM-40673 Part 3] update comments on token revocation
rr-bw Oct 19, 2026
c77b85f
[PM-40673 Part 3] use a valid encrypted key in the password-only reco…
rr-bw Oct 19, 2026
40204ac
[PM-40673 Part 3] correct test comments on password-only recovery and…
rr-bw Oct 19, 2026
2776a6c
[PM-40673 Part 3] to avoid confusion, use 'remembered' for remember-t…
rr-bw Oct 19, 2026
4ec06a3
[PM-40673 Part 3] remove unnecessary comments, and revoke remember to…
rr-bw Oct 19, 2026
d11a83b
[PM-40673 Part 3] use real emailed 2FA code in recovery-code remember…
rr-bw Oct 28, 2026
3267140
[PM-40673 Part 3] refactor remember-token provider exclusions to use …
rr-bw Nov 3, 2026
fb18563
[PM-40673 Part 3] rename "remembered device" wording to "2FA remember…
rr-bw Nov 4, 2026
87057b8
[PM-40673 Part 3] add unit test for 2FA remember token exclusion on R…
rr-bw Nov 4, 2026
e363a0c
[PM-40673 Part 3] add integration tests for remember-token revoke whe…
rr-bw Nov 4, 2026
c3aaad9
[PM-40673 Part 3] add integration test for 2FA remember-token revoke …
rr-bw Nov 4, 2026
162bc16
[PM-40673 Part 3] add integration test that a wrong recovery code doe…
rr-bw Nov 4, 2026
f719c39
[PM-40673 Part 3] add integration test for admin recovery resetting b…
rr-bw Nov 4, 2026
2e7a9eb
[PM-40673 Part 3] add integration tests for 2FA remember-token revoke…
rr-bw Nov 4, 2026
7f46e34
[PM-40673 Part 3] fix using ordering in two-factor integration test f…
rr-bw Nov 4, 2026
3d809bc
[PM-40673 Part 3] update clock comments in ValidateTwoFactorRememberT…
rr-bw Nov 4, 2026
558a332
[PM-40673 Part 3] re-word doc comment on RotateStampsByUserIdAsync
rr-bw Nov 5, 2026
6ce9ded
[PM-40673 Part 3] grant premium in Duo and YubiKey revoke tests so 2F…
rr-bw Nov 5, 2026
61890b5
[PM-40673 Part 3] revoke 2FA remember tokens when emergency access ta…
rr-bw Nov 5, 2026
b13474f
[PM-40673 Part 3] assert that a rejected emergency access takeover do…
rr-bw Nov 5, 2026
5b3c6e1
[PM-40673 Part 3] add integration test for 2FA remember token revoke …
rr-bw Nov 5, 2026
37b4443
[PM-40673 Part 3] use the fake clock for token expiry in ValidateTwoF…
rr-bw Nov 6, 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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace Bit.Admin.Auth.Jobs;

/// <summary>
/// Clears out remember-token rows whose expiry has passed. Hygiene rather than correctness β€” an
/// Clears out `TwoFactorRememberToken` rows whose expiry has passed. Hygiene rather than correctness β€” an
/// expired row is refused at validation time regardless, the row count is bounded by the number of
/// devices, and the expiry index makes the sweep cheap. Weekly is sufficient.
/// </summary>
Expand Down
8 changes: 4 additions & 4 deletions src/Core/Auth/Entities/TwoFactorRememberToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace Bit.Core.Auth.Entities;

/// <summary>
/// Server-side state backing a single remembered device's two-factor "remember me" token.
/// Server-side state backing the 2FA remember token issued to a single device.
/// One row per (<see cref="UserId"/>, <see cref="DeviceId"/>).
/// </summary>
/// <remarks>
Expand All @@ -19,12 +19,12 @@ public class TwoFactorRememberToken : ITableObject<Guid>

/// <summary>
/// The owning user. Derivable through <see cref="DeviceId"/>, but stored directly so that
/// revoking every remembered device for one user is an index seek.
/// revoking every 2FA remember token for one user is an index seek.
/// </summary>
public Guid UserId { get; set; }

/// <summary>
/// The remembered device. Note this is <c>Device.Id</c>, not the client-supplied device identifier.
/// The device the 2FA remember token was issued to. Note this is <c>Device.Id</c>, not the client-supplied device identifier.
/// </summary>
public Guid DeviceId { get; set; }

Expand All @@ -35,7 +35,7 @@ public class TwoFactorRememberToken : ITableObject<Guid>
public string Stamp { get; set; } = null!;

/// <summary>
/// When this device was first remembered. Preserved when the row is re-issued.
/// When a 2FA remember token was first issued for this device. Preserved when the row is re-issued.
/// </summary>
public DateTime CreationDate { get; set; } = DateTime.UtcNow;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public class TwoFactorRememberTokenable : ExpiringTokenable
public string Stamp { get; internal set; } = null!;

// TODO: PM-44171 - Consider removing SecurityStamp from this token. The row's Stamp already
// covers the same ground more precisely, and carrying the user stamp ties remember-me lifetime
// covers the same ground more precisely, and carrying the user stamp ties 2FA remember token lifetime

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Emergency access takeover is also a 2FA teardown: FinishRecoveryTakeoverAsync and the obsolete PasswordAsync in EmergencyAccessService both clear the grantor's 2FA providers. Neither calls RevokeAllForUserAsync. Today the grantor's 2FA remember tokens stop working only because takeover rotates User.SecurityStamp, which this TODO proposes to remove from the token.

Could we:

  1. Call RevokeAllForUserAsync(grantor.Id) in both takeover paths, before ReplaceAsync, to match the other revoke call sites. Also add emergency access takeover to the revocation table in the readme.
  2. Add unit tests in EmergencyAccessServiceTests (revoke runs, and a revoke failure leaves 2FA intact), and an integration test in EmergencyAccessControllerTest. The integration test seeds a TwoFactorRememberToken row and asserts that its Stamp rotated after POST /emergency-access/{id}/password.
  3. Ask QA to include emergency access takeover in their testing: the grantor gets a 2FA remember token, the grantee completes a takeover, a second factor is enabled on the grantor again, and the old 2FA remember token no longer skips the 2FA challenge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// to every user stamp rotation. Any removal has to stay backwards compatible: tokens already
// issued carry this field, so treat it as optional on read before dropping it.
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Bit.Core.Auth.Repositories;
public interface ITwoFactorRememberTokenRepository
{
/// <summary>
/// Reads the row for one remembered device, or null if the device has never been remembered.
/// Reads the row for the 2FA remember token issued to one device, or null if none has ever been issued for it.
Comment thread
JaredSnider-Bitwarden marked this conversation as resolved.
Resolved
/// </summary>
Task<TwoFactorRememberToken?> GetByUserIdDeviceIdAsync(Guid userId, Guid deviceId);

Expand All @@ -21,7 +21,7 @@ public interface ITwoFactorRememberTokenRepository
Task<TwoFactorRememberToken> UpsertAsync(TwoFactorRememberToken token);

/// <summary>
/// Rotates the <c>Stamp</c> on every row for this user, invalidating all of their remember
/// Rotates the <c>Stamp</c> on every row for this user, invalidating all of their 2FA remember
/// tokens at once. Rows are kept.
/// </summary>
/// <remarks>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
Expand Down Expand Up @@ -41,6 +42,7 @@ public class EmergencyAccessService : IEmergencyAccessService
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _dataProtectorTokenizer;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IRevokeTwoFactorRememberTokensCommand _revokeTwoFactorRememberTokensCommand;

public EmergencyAccessService(
IEmergencyAccessRepository emergencyAccessRepository,
Expand All @@ -55,7 +57,8 @@ public EmergencyAccessService(
GlobalSettings globalSettings,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPolicyRequirementQuery policyRequirementQuery)
IPolicyRequirementQuery policyRequirementQuery,
IRevokeTwoFactorRememberTokensCommand revokeTwoFactorRememberTokensCommand)
{
_emergencyAccessRepository = emergencyAccessRepository;
_organizationUserRepository = organizationUserRepository;
Expand All @@ -70,6 +73,7 @@ public EmergencyAccessService(
_dataProtectorTokenizer = dataProtectorTokenizer;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_policyRequirementQuery = policyRequirementQuery;
_revokeTwoFactorRememberTokensCommand = revokeTwoFactorRememberTokensCommand;
}

public async Task<Entities.EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
Expand Down Expand Up @@ -402,6 +406,11 @@ public async Task<IdentityResult> FinishRecoveryTakeoverAsync(
// Disable New Device Verification since it will otherwise block logins
grantor.VerifyDevices = false;

// Takeover removes the grantor's 2FA, so their 2FA remember tokens must stop being honored too.
// The changes above are only in memory until the save below. Revoking first means a failed revoke
// persists nothing, so the stored grantor (including their 2FA) is unchanged and the takeover can be retried.
await _revokeTwoFactorRememberTokensCommand.RevokeAllForUserAsync(grantor.Id);

await _userRepository.ReplaceAsync(grantor);

// Remove grantor from all organizations unless Owner
Expand Down Expand Up @@ -437,6 +446,12 @@ public async Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string
grantor.SetTwoFactorProviders([]);
// Disable New Device Verification since it will otherwise block logins
grantor.VerifyDevices = false;

// Takeover removes the grantor's 2FA, so their 2FA remember tokens must stop being honored too.
// The changes above are only in memory until the save below. Revoking first means a failed revoke
// persists nothing, so the stored grantor (including their 2FA) is unchanged and the takeover can be retried.
await _revokeTwoFactorRememberTokensCommand.RevokeAllForUserAsync(grantor.Id);

await _userRepository.ReplaceAsync(grantor);

// Remove grantor from all organizations unless Owner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
public interface IIssueTwoFactorRememberTokenCommand
{
/// <summary>
/// Records that this device is remembered and mints the token the client presents to skip the
/// Records a 2FA remember token for this device and mints the token the client presents to skip the
/// two-factor challenge on its next login.
/// </summary>
/// <remarks>
/// Writing the row assigns it a fresh stamp, so any token previously issued for this device
/// stops being honored β€” one live remember token per device, newest wins.
/// stops being honored β€” one live 2FA remember token per device, newest wins.
/// </remarks>
/// <returns>The protected token string, to be returned to the client verbatim.</returns>
Task<string> IssueAsync(User user, Device device);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@
public interface IRevokeTwoFactorRememberTokensCommand
{
/// <summary>
/// Stops every remember token this user holds from being honored, on all of their devices.
/// Stops every 2FA remember token recorded for this user from being honored, on all of their devices.
/// </summary>
/// <remarks>
/// Scoped to remember-me only: access tokens, refresh tokens, and live sessions are unaffected,
/// so this does not log the user out anywhere. Devices stay remembered as rows β€” they simply
/// require a two-factor challenge again on next login.
/// </remarks>
Task RevokeAllForUserAsync(Guid userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
public interface IValidateTwoFactorRememberTokenQuery
{
/// <summary>
/// Decides whether a presented remember token entitles this request to skip the two-factor
/// Decides whether a presented 2FA remember token entitles this request to skip the two-factor
/// challenge.
/// </summary>
/// <remarks>
/// A pure read: nothing here writes. If a last-used timestamp is ever wanted, it belongs in its
/// own command rather than folded in here.
/// <para>
/// This validates the token only. Whether the user is required to complete two-factor at all is
/// the caller's decision, made before a remember token is considered.
/// the caller's decision, made before a 2FA remember token is considered.
/// </para>
/// </remarks>
/// <param name="user">The authenticating user.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations;

public class ResetUserTwoFactorCommand(
IUserRepository userRepository,
IRevokeTwoFactorRememberTokensCommand revokeTwoFactorRememberTokensCommand,
TimeProvider timeProvider) : IResetUserTwoFactorCommand
{
public async Task ResetAsync(User user)
{
// Sits before ReplaceAsync so a failure here leaves 2FA intact and a retry re-runs both steps.
await revokeTwoFactorRememberTokensCommand.RevokeAllForUserAsync(user.Id);

user.TwoFactorProviders = null;
user.TwoFactorRecoveryCode = null;
user.RevisionDate = user.AccountRevisionDate = timeProvider.GetUtcNow().UtcDateTime;
Expand Down
45 changes: 37 additions & 8 deletions src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md
Comment thread
JaredSnider-Bitwarden marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

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.

## 2FA remember tokens

### Issuance

When a user completes 2FA, they can optionally select the "Don't ask again on this device for 30 days" checkbox on the `/2fa` screen. When this box is checked, the server records a row in the `TwoFactorRememberToken` table β€” one row per `(UserId, DeviceId)` β€” and issues a 2FA remember token (see `TwoFactorRememberTokenable`) carrying a copy of that row's `Stamp`. On a later login the client presents the token as `TwoFactorProviderType.Remember` and skips the 2FA challenge.

### Revocation

A 2FA remember token is only honored while the `Stamp` it carries still matches its corresponding `TwoFactorRememberToken` row. `IRevokeTwoFactorRememberTokensCommand.RevokeAllForUserAsync` writes a new `Stamp` to every row for a user, which stops every 2FA remember token issued against these rows from being honored at once. The rows are kept, so `CreationDate` still records when each device was first issued a 2FA remember token and `RevisionDate` records when it was cut off.

Four flows revoke, all of them teardowns that leave the account with no effective second factor:

| Flow | Where |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| An administrator resets a member's 2FA | `ResetUserTwoFactorCommand.ResetAsync` |
| A user disables 2FA with a recovery code | `UserService.RecoverTwoFactorAsync` |
| A user removes a provider and 2FA is no longer enabled | `UserService.DisableTwoFactorProviderAsync` |
| A grantee takes over an account through emergency access | `EmergencyAccessService.FinishRecoveryTakeoverAsync` and the obsolete `PasswordAsync` |

Removing a provider does **not** revoke if the user still has working 2FA, so their 2FA remember tokens stay valid. A provider that requires premium does not count when the user has no premium access, so removing the only non-premium provider revokes even though a premium-only provider remains.

A password change or account key rotation also stops every 2FA remember token from being honored, without a call to `RevokeAllForUserAsync`. Each `TwoFactorRememberTokenable` carries a copy of `User.SecurityStamp` from the time it was issued. The server compares that copy with the current `User.SecurityStamp` on every use. Both events rotate `User.SecurityStamp`, so no existing 2FA remember token matches.

Untrusting a device (Trusted Device Encryption) does not revoke 2FA remember tokens β€” that is a separate mechanism.

### Cleanup

Revoked `TwoFactorRememberToken` rows stay until their `ExpirationDate` passes. `DeleteExpiredTwoFactorRememberTokensJob` then deletes them.

## 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.
Expand Down Expand Up @@ -59,14 +88,14 @@ Replays of a still-valid token against the same `(UserId, ProviderType)` are int

Every per-provider 2FA management endpoint follows the same shape:

| Provider | GET (mints) | Chain (replays) | PUT (replays) | DELETE (replays) |
| -------------- | --------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| 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` | `POST /two-factor/send-email` | `PUT /two-factor/email` | `DELETE /two-factor/email` |
| Provider | GET (mints) | Chain (replays) | PUT (replays) | DELETE (replays) |
| -------------- | --------------------------------------------- | ----------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
| 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` | `POST /two-factor/send-email` | `PUT /two-factor/email` | `DELETE /two-factor/email` |

The Chain column captures interior steps that replay an existing UV token and return data the next call needs β€” for WebAuthn, `get-webauthn-challenge` replays the `get-webauthn` token and returns FIDO2 registration options for the PUT; for Email, `send-email` replays the `get-email` token and ships an OTP to the user's inbox that the PUT then carries back alongside the same UV token. No new UV token is minted at the chain step; the original one stays valid through the PUT.

Expand Down
Loading
Loading