Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a447c74
[PM-40673 Part 1] add TwoFactorRememberToken.cs and ITwoFactorRemembe…
rr-bw Sep 22, 2026
272ccb5
[PM-40673 Part 1] add TwoFactorRememberToken.sql, migrator script, st…
rr-bw Sep 23, 2026
18a82a3
[PM-40673 Part 1] add Entity Framework infrastructure
rr-bw Sep 23, 2026
324cbfe
[PM-40673 Part 1] add TwoFactorRememberTokenable.cs
rr-bw Sep 23, 2026
231a89c
[PM-40673 Part 1] add commands for issue/revoke, and query for valida…
rr-bw Sep 23, 2026
7dda6bf
[PM-40673 Part 1] add DeleteExpiredTwoFactorRememberTokensJob.cs
rr-bw Sep 23, 2026
d56f90b
[PM-40673 Part 1] link TODO comment to specific Jira ticket
rr-bw Sep 28, 2026
99e9ddc
[PM-40673 Part 1] remove prefixes from test comments
rr-bw Oct 2, 2026
5614074
[PM-40673 Part 1] rename stored procedure to _DeleteManyExpired
rr-bw Oct 2, 2026
39edfbc
[PM-40673 Part 1] split upsert (_Save) into _Create and _Update
rr-bw Oct 2, 2026
220186e
[PM-40673 Part 1] update expiration time comparison check
rr-bw Oct 2, 2026
c601599
[PM-40673 Part 1] use FakeTimeProvider in ValidateTwoFactorRememberTo…
rr-bw Oct 2, 2026
161cd50
[PM-40673 Part 1] add a TwoFactorRememberTokenableFactory
rr-bw Oct 2, 2026
afec27c
[PM-40673 Part 1] update UpsertAsync to prevent concurrent duplicate …
rr-bw Oct 2, 2026
89181ff
[PM-40673 Part 1] increase DeleteManyExpired batch size to 1000
rr-bw Oct 2, 2026
edda633
[PM-40673 Part 1] restore snapshots and re-generate migrations
rr-bw Oct 2, 2026
4859929
[PM-40673 Part 1] update trigger to be unique for remember token dele…
rr-bw Oct 2, 2026
1c99585
[PM-40673 Part 1] remove unused row variable
rr-bw Oct 2, 2026
e87bf08
[PM-40673 Part 1] update rotate stamp methods to pass in revision tim…
rr-bw Oct 2, 2026
2717b9c
[PM-40673 Part 1] use CombGuid.Generate() instead of obselete CoreHel…
rr-bw Oct 5, 2026
500696c
[PM-40673 Part 1] run dotnet format
rr-bw Oct 5, 2026
f9de219
[PM-40673 Part 1] restore snapshots and re-generate migrations
rr-bw Oct 9, 2026
b1cd61d
[PM-40673 Part 1] add check for org-required-2FA when checking if 2FA…
rr-bw Oct 9, 2026
e4c1ea9
[PM-40673 Part 1] remove comment, update comment
rr-bw Oct 12, 2026
1983d85
[PM-40673 Part 1] move scheduled DeleteExpiredTwoFactorRememberTokens…
rr-bw Oct 14, 2026
a27f3cd
[PM-40673 Part 1] remove redundant 2FA-enabled check and organization…
rr-bw Oct 17, 2026
523502c
[PM-40673 Part 1] bump migrations
rr-bw Oct 17, 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
36 changes: 36 additions & 0 deletions src/Admin/Auth/Jobs/DeleteExpiredTwoFactorRememberTokensJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Bit.Core;
using Bit.Core.Auth.Repositories;
using Bit.Core.Jobs;
using Quartz;

namespace Bit.Admin.Auth.Jobs;

/// <summary>
/// Clears out remember-token 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>
public class DeleteExpiredTwoFactorRememberTokensJob : BaseJob
{
private readonly ITwoFactorRememberTokenRepository _twoFactorRememberTokenRepository;
private readonly TimeProvider _timeProvider;

public DeleteExpiredTwoFactorRememberTokensJob(
ITwoFactorRememberTokenRepository twoFactorRememberTokenRepository,
TimeProvider timeProvider,
ILogger<DeleteExpiredTwoFactorRememberTokensJob> logger)
: base(logger)
{
_twoFactorRememberTokenRepository = twoFactorRememberTokenRepository;
_timeProvider = timeProvider;
}

protected async override Task ExecuteJobAsync(IJobExecutionContext context)
{
_logger.LogInformation(
Constants.BypassFiltersEventId, "Execute job task: DeleteExpiredTwoFactorRememberTokensAsync");
await _twoFactorRememberTokenRepository.DeleteExpiredAsync(_timeProvider.GetUtcNow().UtcDateTime);
_logger.LogInformation(
Constants.BypassFiltersEventId, "Finished job task: DeleteExpiredTwoFactorRememberTokensAsync");
}
}
7 changes: 7 additions & 0 deletions src/Admin/Jobs/JobsHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ public override async Task StartAsync(CancellationToken cancellationToken)
.StartNow()
.WithCronSchedule("0 0 22 ? * FRI", x => x.InTimeZone(timeZone))
.Build();
var everySaturdayAt430amTrigger = TriggerBuilder.Create()
.WithIdentity("EverySaturdayAt430amTrigger")
.StartNow()
.WithCronSchedule("0 30 4 ? * SAT", x => x.InTimeZone(timeZone))
.Build();
var everySaturdayAtMidnightTrigger = TriggerBuilder.Create()
.WithIdentity("EverySaturdayAtMidnightTrigger")
.StartNow()
Expand Down Expand Up @@ -88,6 +93,7 @@ public override async Task StartAsync(CancellationToken cancellationToken)
{
new Tuple<Type, ITrigger>(typeof(DeleteSendsJob), everyFiveMinutesTrigger),
new Tuple<Type, ITrigger>(typeof(DatabaseExpiredGrantsJob), everyFridayAt10pmTrigger),
new Tuple<Type, ITrigger>(typeof(DeleteExpiredTwoFactorRememberTokensJob), everySaturdayAt430amTrigger),
new Tuple<Type, ITrigger>(typeof(DeleteCiphersJob), everyDayAtMidnightUtc),
new Tuple<Type, ITrigger>(typeof(DatabaseExpiredSponsorshipsJob), everyMondayAtMidnightTrigger),
new Tuple<Type, ITrigger>(typeof(DeleteAuthRequestsJob), everyFifteenMinutesTrigger),
Expand Down Expand Up @@ -122,6 +128,7 @@ public static void AddJobsServices(IServiceCollection services, bool selfHosted)
services.AddTransient<DatabaseUpdateStatisticsJob>();
services.AddTransient<DatabaseRebuildlIndexesJob>();
services.AddTransient<DatabaseExpiredGrantsJob>();
services.AddTransient<DeleteExpiredTwoFactorRememberTokensJob>();
services.AddTransient<DatabaseExpiredSponsorshipsJob>();
services.AddTransient<DeleteSendsJob>();
services.AddTransient<DeleteCiphersJob>();
Expand Down
62 changes: 62 additions & 0 deletions src/Core/Auth/Entities/TwoFactorRememberToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Utilities;

namespace Bit.Core.Auth.Entities;

/// <summary>
/// Server-side state backing a single remembered device's two-factor "remember me" token.
/// One row per (<see cref="UserId"/>, <see cref="DeviceId"/>).
/// </summary>
/// <remarks>
/// A token issued to a device carries a copy of that device's <see cref="Stamp"/>, and is honored only
/// while the two match. Writing a new <see cref="Stamp"/> therefore invalidates every token previously
/// issued for the device, leaving the user's other devices and their access and refresh tokens untouched.
/// </remarks>
public class TwoFactorRememberToken : ITableObject<Guid>
{
public Guid Id { get; set; }

/// <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.
/// </summary>
public Guid UserId { get; set; }

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

/// <summary>
/// The value a presented token's stamp is compared against. Replaced to revoke.
/// </summary>
[MaxLength(50)]
public string Stamp { get; set; } = null!;

/// <summary>
/// When this device was first remembered. Preserved when the row is re-issued.
/// </summary>
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
Comment thread
enmande marked this conversation as resolved.
Resolved

/// <summary>
/// When <see cref="Stamp"/> was last written, whether by re-issuance or by a revocation.
/// </summary>
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;

/// <summary>
/// When this row stops being honored, regardless of <see cref="Stamp"/>.
/// </summary>
public DateTime ExpirationDate { get; set; }

/// <summary>
/// Initializes <see cref="Id"/> to a new COMB GUID.
/// </summary>
public void SetNewId()
{
if (Id == default)
{
Id = CombGuid.Generate();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Bit.Core.Entities;

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

/// <summary>Mints <see cref="TwoFactorRememberTokenable"/> instances with the operator-configured lifetime.</summary>
public interface ITwoFactorRememberTokenableFactory
{
/// <summary>
/// Creates a token bound to the given user and device, carrying <paramref name="stamp"/>, and expiring
/// after the configured lifetime.
/// </summary>
TwoFactorRememberTokenable CreateToken(User user, Device device, string stamp);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using System.Text.Json.Serialization;
using Bit.Core.Tokens;

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

/// <summary>
/// The "remember this device" token a client presents to satisfy two-factor authentication without
/// being challenged again.
/// </summary>
/// <remarks>
/// The token is encrypted and signed, so a client can neither read nor alter its contents. It names
/// the device it was issued to and carries two stamps that are compared against server-side state on
/// every use: <see cref="SecurityStamp"/> against the user, and <see cref="Stamp"/> against that
/// device's <c>TwoFactorRememberToken</c> row. Minted only through
/// <see cref="ITwoFactorRememberTokenableFactory"/>, which applies the configured lifetime.
/// </remarks>
public class TwoFactorRememberTokenable : ExpiringTokenable
{
public const string ClearTextPrefix = "BwTwoFactorRemember_";
public const string DataProtectorPurpose = "TwoFactorRememberTokenDataProtector";
public const string TokenIdentifier = "TwoFactorRememberToken";

// Binding properties use [JsonInclude] internal set so only the factory (in Bit.Core) can mint a
// token. [JsonInclude] is required: JsonSerializer with default options ignores non-public
// setters, and deserialized tokens would silently come back with default values.
[JsonInclude]
public string Identifier { get; internal set; } = TokenIdentifier;

[JsonInclude]
public Guid UserId { get; internal set; }

/// <summary>
/// The primary key of the device this token was issued to, used to locate the row to compare
/// <see cref="Stamp"/> against.
/// </summary>
[JsonInclude]
public Guid DeviceId { get; internal set; }

/// <summary>
/// The client-generated device identifier from the request that minted this token. Compared
/// against the presenting request's identifier, which binds the token to one device without
/// needing a database read.
/// </summary>
[JsonInclude]
public string DeviceIdentifier { get; internal set; } = null!;

/// <summary>
/// A copy of the issuing device's row stamp. Device-scoped.
/// </summary>
[JsonInclude]
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
// 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>
/// A copy of <c>User.SecurityStamp</c> as it stood at issuance. Account-scoped.
/// </summary>
[JsonInclude]
public string SecurityStamp { get; internal set; } = null!;

// Leaves ExpirationDate at its default, so a token constructed without going through the factory
// is expired and therefore never valid.
[JsonConstructor]
public TwoFactorRememberTokenable()
{
}

internal TwoFactorRememberTokenable(
Guid userId, Guid deviceId, string deviceIdentifier, string stamp, string securityStamp)
{
if (userId == default)
{
throw new ArgumentException("User id cannot be empty.", nameof(userId));
}

if (deviceId == default)
{
throw new ArgumentException("Device id cannot be empty.", nameof(deviceId));
}

if (string.IsNullOrWhiteSpace(deviceIdentifier))
{
throw new ArgumentException("Device identifier cannot be null or whitespace.", nameof(deviceIdentifier));
}

if (string.IsNullOrWhiteSpace(stamp))
{
throw new ArgumentException("Stamp cannot be null or whitespace.", nameof(stamp));
}

if (string.IsNullOrWhiteSpace(securityStamp))
{
throw new ArgumentException("Security stamp cannot be null or whitespace.", nameof(securityStamp));
}

Identifier = TokenIdentifier;
UserId = userId;
DeviceId = deviceId;
DeviceIdentifier = deviceIdentifier;
Stamp = stamp;
SecurityStamp = securityStamp;
}

/// <summary>
/// Checks only that the deserialized fields are present and well-formed. Comparing them against
/// the user and the device's row requires state this type does not have, and happens in
/// <c>ValidateTwoFactorRememberTokenQuery</c>.
/// </summary>
protected override bool TokenIsValid() =>
Identifier == TokenIdentifier && UserId != default && DeviceId != default
&& !string.IsNullOrWhiteSpace(DeviceIdentifier)
&& !string.IsNullOrWhiteSpace(Stamp)
&& !string.IsNullOrWhiteSpace(SecurityStamp);

/// <summary>
/// Unprotects a presented token and checks everything that can be checked without server-side
/// state: tampering, a wrong data-protection purpose, the token's own expiry, and the presence of
/// its fields. The caller still compares the result against the user and the device's row.
/// </summary>
/// <param name="tokenable">The unprotected token, or null when it could not be unprotected.</param>
public static TokenableValidationError? ValidateTwoFactorRememberToken(
IDataProtectorTokenFactory<TwoFactorRememberTokenable> tokenFactory,
string token,
out TwoFactorRememberTokenable? tokenable)
{
if (!tokenFactory.TryUnprotect(token, out tokenable) || tokenable is null)
{
return TokenableValidationError.InvalidToken;
}

if (tokenable.IsExpired)
{
return TokenableValidationError.ExpiringTokenables.Expired;
}

return tokenable.Valid ? null : TokenableValidationError.InvalidToken;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Bit.Core.Entities;
using Bit.Core.Settings;

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

/// <inheritdoc />
public class TwoFactorRememberTokenableFactory(
IGlobalSettings globalSettings,
TimeProvider timeProvider) : ITwoFactorRememberTokenableFactory
{
/// <inheritdoc />
public TwoFactorRememberTokenable CreateToken(User user, Device device, string stamp) =>
new(user.Id, device.Id, device.Identifier, stamp, user.SecurityStamp)
{
ExpirationDate = timeProvider.GetUtcNow().UtcDateTime
.AddDays(globalSettings.TwoFactorRememberTokenLifetimeInDays),
};
}
46 changes: 46 additions & 0 deletions src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Bit.Core.Auth.Entities;

namespace Bit.Core.Auth.Repositories;

/// <summary>
/// Point lookups and scoped writes only, deliberately: no ad-hoc queries, so that every operation here
/// is expressible against a partitioned document store if this ever needs a second implementation.
/// </summary>
public interface ITwoFactorRememberTokenRepository
{
/// <summary>
/// Reads the row for one remembered device, or null if the device has never been remembered.
/// </summary>
Task<TwoFactorRememberToken?> GetByUserIdDeviceIdAsync(Guid userId, Guid deviceId);

/// <summary>
/// Creates the row for this user and device, or updates the existing one in place,
/// preserving its <c>CreationDate</c>. The caller supplies a fresh <c>Stamp</c>, so any
/// token previously issued for this device stops validating.
/// </summary>
Task<TwoFactorRememberToken> UpsertAsync(TwoFactorRememberToken token);

/// <summary>
/// Rotates the <c>Stamp</c> on every row for this user, invalidating all of their remember
/// tokens at once. Rows are kept.
/// </summary>
/// <remarks>
/// The new stamp is generated here rather than by the caller: nothing above this layer needs to
/// know the value, and generating it in one place keeps every provider writing the same thing.
/// </remarks>
/// <param name="revisionDate">
/// The value written to <c>RevisionDate</c> on every rotated row, supplied by the caller so that
/// every provider writes one instant.
/// </param>
Task RotateStampsByUserIdAsync(Guid userId, DateTime revisionDate);

/// <summary>
/// Deletes every row that expired before <paramref name="now"/>. Hygiene only — an expired row is
/// already refused at validation time.
/// </summary>
/// <param name="now">
/// The instant to compare against, supplied by the caller so that every provider and the stored
/// procedure agree on one value.
/// </param>
Task DeleteExpiredAsync(DateTime now);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Bit.Core.Entities;

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
/// 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.
/// </remarks>
/// <returns>The protected token string, to be returned to the client verbatim.</returns>
Task<string> IssueAsync(User user, Device device);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;

public interface IRevokeTwoFactorRememberTokensCommand
{
/// <summary>
/// Stops every remember token this user holds 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);
}
Loading
Loading