UserTwoFactor
One user's TOTP enrolment, held in its own table so signing in does not load a secret and a set of recovery codes it will not use.
Reach it through IAuthTwoFactorService<TUser> for anything that changes it. Read it directly only to ask whether a user is enrolled, which LoginAsync decides for itself: an enabled row is what makes it demand a code.
DANGER
UserTwoFactor is a database entity. Never return it from an endpoint: it carries the TOTP secret, the pending secret, and the surrogate keys. Map it to a DTO that exposes only the fields the client needs, such as IsEnabled.
Usage
using Microsoft.EntityFrameworkCore;
using AlmightyShogun.AspNet.Auth.Credentials;
public sealed class TwoFactorStatus(AppDbContext database)
{
public Task<bool> IsEnrolledAsync(int userId)
=> database.UserTwoFactors
.Where(twoFactor => twoFactor.IsEnabled)
.AnyAsync(twoFactor => twoFactor.UserId == userId);
}Fields
Id: int
The surrogate key of the enrolment row.
UserId: int
The enrolled user, uniquely indexed so an account cannot hold two enrolments. Cascades with the user.
IsEnabled: bool
Whether enrolment was confirmed. Set only when CompleteEnrolmentAsync verifies a code against the pending secret, so a row exists while enrolment is still half-finished.
Default: false
Secret: string
The shared secret in force, encrypted with ASP.NET Core data protection rather than hashed, because verification needs the original value back. Empty until an enrolment is confirmed.
PendingSecret: string?
The encrypted secret an enrolment in progress is offering, or null when none is outstanding. CompleteEnrolmentAsync promotes it to Secret once a code proves it, so a working second factor survives an abandoned re-enrolment.
Default: null
PendingSecretExpiresAt: DateTimeOffset?
When the pending secret stops being confirmable, set from PendingSecretMinutes when BeginEnrolmentAsync issued it. A confirmation arriving later is refused as a wrong code.
Default: null
LastWindow: long?
The time step of the last accepted code. A code from that step or earlier is refused, so an intercepted code cannot be replayed inside its own window.
Default: null
CreatedAt: DateTimeOffset
When the enrolment row was first created, which is when the user began enrolling rather than when they finished.
RecoveryCodes: List<TwoFactorRecoveryCode>
The single-use codes issued at enrolment. One row each, so spending one is an update rather than a rewrite of the whole set.
Default: []