AuthTwoFactorService
Enrols a user in TOTP two-factor authentication and verifies their codes. Application code depends on IAuthTwoFactorService<TUser>; the shared secret is encrypted with ASP.NET Core data protection and the recovery codes are stored only as hashes.
WARNING
Enrolling is the decision, and it is yours: nothing is required of an account until CompleteEnrolmentAsync turns it on. Once it is on, LoginAsync stops opening a session on the password alone and demands a code through CompleteTwoFactorLoginAsync. To let a remembered device skip the prompt, leave the account unenrolled or disable the enrolment; there is no per-sign-in way past it.
BeginEnrolmentAsync
Generates a secret, stores it encrypted as a pending enrolment, and returns it alongside an otpauth:// URI to render as a QR code. Calling it again discards the previous pending secret and offers a fresh one, so only the newest QR can be confirmed.
A second factor already in force is left untouched, codes and all, until CompleteEnrolmentAsync verifies a code against the pending secret. Abandoning enrolment halfway therefore changes nothing, and the pending secret stops being confirmable TwoFactorPolicy.PendingSecretMinutes after it was issued. issuer is the label an authenticator app shows, and is ignored when TwoFactorPolicy.Issuer is set.
using Microsoft.AspNetCore.Mvc;
using AlmightyShogun.AspNet.Auth;
using Microsoft.AspNetCore.Authorization;
using AlmightyShogun.AspNet.Auth.Credentials;
[ApiController]
[Authorize]
[Route("auth/two-factor")]
public sealed class TwoFactorController(
IAuthTwoFactorService<AppUser> twoFactor
) : ControllerBase
{
[HttpPost("begin")]
public async Task<ActionResult<AuthTwoFactorResult>> Begin(
CancellationToken cancellationToken
) => Ok(await twoFactor.BeginEnrolmentAsync(
User.GetCurrentUserId(),
"Example",
cancellationToken
));
}Type signature
public Task<AuthTwoFactorResult> BeginEnrolmentAsync(
Guid identifier,
string issuer,
CancellationToken cancellationToken = default
);CompleteEnrolmentAsync
Verifies a code against the pending secret, promotes it to the secret in force, turns two-factor on, and returns freshly generated recovery codes. Promotion, code replacement, and enabling happen in one transaction, so a half-applied enrolment cannot leave an account with no working factor.
Only hashes of the recovery codes are kept, so these are shown to the user once and cannot be produced again. Throws InvalidTwoFactorCodeException when the code is wrong, when there is no pending enrolment to complete, and when the pending secret has expired. How many codes are issued is set by TwoFactorPolicy.RecoveryCodeCount.
using AlmightyShogun.AspNet.Auth;
using AlmightyShogun.AspNet.Auth.Credentials;
IReadOnlyList<string> recoveryCodes = await twoFactor
.CompleteEnrolmentAsync(User.GetCurrentUserId(), code);Type signature
public Task<IReadOnlyList<string>> CompleteEnrolmentAsync(
Guid identifier,
string code,
CancellationToken cancellationToken = default
);VerifyAsync
Checks a submitted value as a TOTP code first and as a recovery code second, spending the recovery code when it matches. Both are claimed with a guarded update rather than read and then written, so two requests presenting the same code at once cannot both be accepted.
Returns false for a wrong code, an unreadable secret, a recovery code that was already spent, and an enrolment that was begun but never confirmed, rather than throwing, so the caller decides how to report it. CompleteTwoFactorLoginAsync calls this to finish a sign-in and turns that false into InvalidTwoFactorCodeException. A user with no enrolment row at all throws that exception from here too, so call this only for a user you have already established is enrolled.
The presented code is claimed against the same failure budget the password is, so a wrong one costs a lockout attempt and repeated guesses lock the account at LockoutPolicy.MaxFailedAttempts. The claim is made before the code is checked, so AccountLockedException is thrown while a lockout is in force and a valid TOTP or recovery code is refused until it expires; an accepted code clears the whole run. An enrolment that is not enabled or carries no secret is refused before anything is claimed, and none of this happens unless LockoutPolicy.Enabled is set, which it is not by default.
using AlmightyShogun.AspNet.Auth.Credentials;
bool accepted = await twoFactor.VerifyAsync(userId, code);Type signature
public Task<bool> VerifyAsync(
Guid identifier,
string code,
CancellationToken cancellationToken = default
);DisableAsync
Deletes the enrolment along with every recovery code, returning the account to password-only sign-in. A user with no enrolment is not an error and nothing is written.
Demand a fresh password or a valid code before calling this. Otherwise a stolen session is enough to strip the second factor off an account.
using AlmightyShogun.AspNet.Auth;
using AlmightyShogun.AspNet.Auth.Credentials;
await twoFactor.DisableAsync(User.GetCurrentUserId());Type signature
public Task DisableAsync(
Guid identifier,
CancellationToken cancellationToken = default
);