Repository navigation
[PM-40673] Two Factor Remember Token Updates (Part 1) #8427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 272ccb5
[PM-40673 Part 1] add TwoFactorRememberToken.sql, migrator script, st…
rr-bw 18a82a3
[PM-40673 Part 1] add Entity Framework infrastructure
rr-bw 324cbfe
[PM-40673 Part 1] add TwoFactorRememberTokenable.cs
rr-bw 231a89c
[PM-40673 Part 1] add commands for issue/revoke, and query for valida…
rr-bw 7dda6bf
[PM-40673 Part 1] add DeleteExpiredTwoFactorRememberTokensJob.cs
rr-bw d56f90b
[PM-40673 Part 1] link TODO comment to specific Jira ticket
rr-bw 99e9ddc
[PM-40673 Part 1] remove prefixes from test comments
rr-bw 5614074
[PM-40673 Part 1] rename stored procedure to _DeleteManyExpired
rr-bw 39edfbc
[PM-40673 Part 1] split upsert (_Save) into _Create and _Update
rr-bw 220186e
[PM-40673 Part 1] update expiration time comparison check
rr-bw c601599
[PM-40673 Part 1] use FakeTimeProvider in ValidateTwoFactorRememberTo…
rr-bw 161cd50
[PM-40673 Part 1] add a TwoFactorRememberTokenableFactory
rr-bw afec27c
[PM-40673 Part 1] update UpsertAsync to prevent concurrent duplicate …
rr-bw 89181ff
[PM-40673 Part 1] increase DeleteManyExpired batch size to 1000
rr-bw edda633
[PM-40673 Part 1] restore snapshots and re-generate migrations
rr-bw 4859929
[PM-40673 Part 1] update trigger to be unique for remember token dele…
rr-bw 1c99585
[PM-40673 Part 1] remove unused row variable
rr-bw e87bf08
[PM-40673 Part 1] update rotate stamp methods to pass in revision tim…
rr-bw 2717b9c
[PM-40673 Part 1] use CombGuid.Generate() instead of obselete CoreHel…
rr-bw 500696c
[PM-40673 Part 1] run dotnet format
rr-bw f9de219
[PM-40673 Part 1] restore snapshots and re-generate migrations
rr-bw b1cd61d
[PM-40673 Part 1] add check for org-required-2FA when checking if 2FA…
rr-bw e4c1ea9
[PM-40673 Part 1] remove comment, update comment
rr-bw 1983d85
[PM-40673 Part 1] move scheduled DeleteExpiredTwoFactorRememberTokens…
rr-bw a27f3cd
[PM-40673 Part 1] remove redundant 2FA-enabled check and organization…
rr-bw 523502c
[PM-40673 Part 1] bump migrations
rr-bw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
36 changes: 36 additions & 0 deletions
36
src/Admin/Auth/Jobs/DeleteExpiredTwoFactorRememberTokensJob.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
||
| /// <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(); | ||
| } | ||
| } | ||
| } | ||
13 changes: 13 additions & 0 deletions
13
src/Core/Auth/Models/Business/Tokenables/ITwoFactorRememberTokenableFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
140 changes: 140 additions & 0 deletions
140
src/Core/Auth/Models/Business/Tokenables/TwoFactorRememberTokenable.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
18 changes: 18 additions & 0 deletions
18
src/Core/Auth/Models/Business/Tokenables/TwoFactorRememberTokenableFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
46
src/Core/Auth/Repositories/ITwoFactorRememberTokenRepository.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
17 changes: 17 additions & 0 deletions
17
src/Core/Auth/UserFeatures/TwoFactorAuth/IIssueTwoFactorRememberTokenCommand.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
14 changes: 14 additions & 0 deletions
14
src/Core/Auth/UserFeatures/TwoFactorAuth/IRevokeTwoFactorRememberTokensCommand.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.