Multi-Factor Authentication (MFA)
PearDrop provides flexible multi-factor authentication supporting email, SMS, authenticator apps, and FIDO2 devices.
MFA Overview
Why MFA?
MFA adds a second verification factor beyond passwords:
- Email codes - One-time codes sent to email
- SMS codes - One-time codes via text message
- Authenticator Apps - Time-based codes (Google Authenticator, Microsoft Authenticator)
- Devices - FIDO2 hardware (Windows Hello, YubiKey, Titan key)
MFA Settings Provider
MFA behavior is controlled by IMfaSettingsProvider:
public interface IMfaSettingsProvider
{
Task<bool> IsMfaEnabledAsync(
Guid? tenantId = null,
CancellationToken cancellationToken = default);
}
Override this to enable/disable MFA per tenant or user:
// Custom implementation
public class CustomMfaSettingsProvider : IMfaSettingsProvider
{
public async Task<bool> IsMfaEnabledAsync(Guid? tenantId = null, CancellationToken cancellationToken = default)
{
if (tenantId == Guid.Empty)
return false; // Disable for trial tenants
return true; // Require for paid customers
}
}
// Register in Program.cs
builder.Services.AddScoped<IMfaSettingsProvider, CustomMfaSettingsProvider>();
Email MFA
Send one-time codes to the user's email address.
Configuration
{
"PearDrop": {
"modules": {
"authentication": {
"emailMfaTokenGenerations": 3,
"emailMfaHelpMessageThreshold": 3
}
}
}
}
User Enrollment
User can enroll in email MFA without admin help:
// Client-side: Request MFA code
var command = new EmailMfaRequestedCommand(userId: currentUser.Id);
var result = await commandRunner.ExecuteAsync(command);
// Razor page shows email confirmation interface
// User clicks "Request Code"
// Code sent to user's contact email
// User enters code and submits
Verification Flow
// Validate the code user entered
var validateCommand = new ValidateEmailMfaCodeCommand(
userId: userId,
code: userEnteredCode);
var result = await commandRunner.ExecuteAsync(validateCommand);
if (result.Success)
{
// MFA passed, authentication continues
}
else
{
// Invalid code, user tries again
}
Code Generation Logic
- User requests code → Code #1 generated and emailed
- User requests again (code expired/lost) → Code #2 generated
- This continues up to
emailMfaTokenGenerationslimit (default: 3) - After 3 generations, user must complete authentication or password reset
Recovery
If user can't access email:
- Admin can force password reset via console
- User completes password reset via email link from admin
- Email MFA is cleared during reset
- User re-enrolls if desired
SMS MFA
Send codes via SMS text message.
Configuration
{
"PearDrop": {
"modules": {
"authentication": {
"smsMfaTokenGenerations": 3,
"smsMfaHelpMessageThreshold": 3
}
}
}
}
Setup Requirements
SMS delivery requires external provider (configure in your auth service):
// Register SMS provider
builder.Services.AddScoped<ISmsMfaProvider, TwilioSmsMfaProvider>();
User Enrollment
// User provides phone number
var command = new SmsMfaRequestedCommand(
userId: currentUser.Id,
phoneNumber: "+1234567890");
var result = await commandRunner.ExecuteAsync(command);
// Code sent via SMS
// Validate code
var validateCommand = new ValidateSmsMfaCodeCommand(
userId: userId,
code: userEnteredSmsCode);
var result = await commandRunner.ExecuteAsync(validateCommand);
Code Expiration
Codes expire after a configurable time (typically 5-15 minutes).
Authenticator Apps
TOTP-based codes from apps like Google Authenticator or Microsoft Authenticator.
Benefits
- No email/SMS dependency
- Works offline
- Industry standard (RFC 6238)
- User controls backup codes
Enrollment
Step 1: Initiate Enrollment
var command = new InitiateAuthenticatorAppEnrollmentCommand(
userId: currentUser.Id);
var result = await commandRunner.ExecuteAsync(command);
// Result contains:
// - QR code as base64 PNG
// - Manual entry key for keyboard input
// - Estimated recovery codes count
Step 2: User Scans QR Code
The app displays a QR code containing:
- Issuer: "YourApp"
- Account: user's email
- Secret: cryptographic key
User scans with authenticator app.
Step 3: Verify & Complete Enrollment
var verifyCommand = new EnrollAuthenticatorAppCommand(
userId: userId,
code: userGeneratedFromApp, // 6-digit code from app
name: "My iPhone"); // Device name
var result = await commandRunner.ExecuteAsync(verifyCommand);
if (result.Success)
{
// Enrollment complete
// Recovery codes provided if supported
}
Verification During Sign-in
var command = new AppMfaRequestedCommand(userId: userId);
var result = await commandRunner.ExecuteAsync(command);
// User opens authenticator app, reads 6-digit code
var validateCommand = new ValidateAppMfaCodeCommand(
userId: userId,
code: userGeneratedCode);
var result = await commandRunner.ExecuteAsync(validateCommand);
Revocation
If user loses device or wants to disable:
var command = new RevokeAuthenticatorAppCommand(userId: userId);
await commandRunner.ExecuteAsync(command);
Admin can also revoke:
// Admin removes app enrollment for user
var command = new RevokeAuthenticatorAppCommand(userId: targetUser.Id);
await userCommandRunner.ExecuteAsync(command);
FIDO2 Authenticator Devices
Hardware-based authentication (Windows Hello, YubiKey, Google Titan, etc.).
Benefits
- Phishing-proof (public key cryptography)
- No codes to remember
- Fast and secure
- Biometric support (fingerprint, face)
Architecture
// PearDrop uses Fido2NetLib for protocol support
services.AddScoped<IFido2>(provider =>
{
var fido = provider.GetRequiredService<IFido2Provider>();
return fido.GetFido2Configuration();
});
Enrollment
Step 1: Initiate Registration Challenge
var command = new InitiateAuthenticatorDeviceEnrollmentCommand(
userId: currentUser.Id);
var result = await commandRunner.ExecuteAsync(command);
// Result contains:
// - Challenge (random bytes)
// - Attestation options (which devices allowed)
// - Timeout settings
Step 2: Device Creates Credential
Client-side JavaScript:
// Use Web Authentication API
const credential = await navigator.credentials.create({
publicKey: attestationOptions
});
// credential contains:
// - Public key
// - Attested credential data
// - Signature counter
Step 3: Complete Enrollment
var enrollCommand = new EnrollAuthenticatorDeviceCommand(
userId: userId,
credentialId: credentialBytes,
publicKey: publicKeyBytes,
name: "My YubiKey");
var result = await commandRunner.ExecuteAsync(enrollCommand);
Verification During Sign-in
Step 1: Request Assertion Challenge
var command = new DeviceMfaRequestCommand(userId: userId);
var result = await commandRunner.ExecuteAsync(command);
// Result contains:
// - Challenge
// - Known device credentials
// - Timeout settings
Step 2: Device Signs Challenge
Client-side:
const assertion = await navigator.credentials.get({
publicKey: assertionOptions
});
// assertion contains:
// - Signature
// - Signature counter
// - User verification data
Step 3: Validate Signature
var validateCommand = new ValidateDeviceMfaCommand(
userId: userId,
signatureData: signatureBytes,
clientData: clientDataBytes,
credentialId: credentialIdBytes);
var result = await commandRunner.ExecuteAsync(validateCommand);
Revocation
Remove device enrollment:
var command = new RevokeAuthenticatorDeviceCommand(
userId: userId,
deviceId: deviceIdToRemove);
await commandRunner.ExecuteAsync(command);
Device Remembrance
Skip MFA on trusted devices to improve UX.
Configuration
public interface IDeviceRemembranceSettingsProvider
{
Task<DeviceRemembranceSettings> GetDeviceRemembranceSettingsAsync(
Guid? tenantId = null,
CancellationToken cancellationToken = default);
}
public class DeviceRemembranceSettings
{
public bool IsEnabled { get; set; } = false;
public int TrustWindowDays { get; set; } = 30;
}
Implementation
Override to customize device trust:
public class CustomDeviceRemembranceProvider : IDeviceRemembranceSettingsProvider
{
public async Task<DeviceRemembranceSettings> GetDeviceRemembranceSettingsAsync(
Guid? tenantId = null,
CancellationToken cancellationToken = default)
{
return new DeviceRemembranceSettings
{
IsEnabled = true,
TrustWindowDays = 60
};
}
}
// Register
builder.Services.AddScoped<IDeviceRemembranceSettingsProvider, CustomDeviceRemembranceProvider>();
User Perspective
- User signs in with MFA
- "Remember this device for 60 days" checkbox appears
- User checks and completes MFA
- Device is trusted for 60 days
- Next sign-in on same device skips MFA
Security
- Device identifier hashed and stored securely
- Trust window can be revoked globally by admin
- Trust clears if user changes password or disables all authenticators
MFA Enrollment Page
Typical enrollment workflow in Blazor:
@page "/account/mfa"
@layout UserLayout
@attribute [Authorize]
<div class="mfa-container">
<h2>Manage Two-Factor Authentication</h2>
@if (EnrolledMethods.Any())
{
<div class="enrolled-methods">
<h3>Your Authentication Methods</h3>
@foreach (var method in EnrolledMethods)
{
<div class="method-card">
<span>@method.Name</span>
<button @onclick="() => RevokeMfa(method.Id)">Remove</button>
</div>
}
</div>
}
<div class="add-method">
<h3>Add Authentication Method</h3>
@if (ShowQrCode)
{
<div>
<h4>Scan with Authenticator App</h4>
<img src="@QrCodeImageUrl" alt="QR Code" />
<p>Or enter manually: @EnrollmentSecret</p>
<input type="text" placeholder="Enter 6-digit code" @bind="VerificationCode" />
<button @onclick="VerifyAuthenticatorApp">Verify & Enroll</button>
</div>
}
else
{
<button @onclick="EnrollAuthenticatorApp">Add Authenticator App</button>
<button @onclick="EnrollEmailMfa">Use Email</button>
<button @onclick="EnrollSmsMfa">Use SMS</button>
<button @onclick="EnrollDevice">Add Security Key</button>
}
</div>
</div>
@code {
[Inject] ICommandRunner CommandRunner { get; set; } = default!;
[Inject] IQueryRunner QueryRunner { get; set; } = default!;
private List<EnrolledMfaMethod> EnrolledMethods = new();
private bool ShowQrCode = false;
private string? QrCodeImageUrl;
private string? EnrollmentSecret;
private string? VerificationCode;
protected override async Task OnInitializedAsync()
{
await LoadEnrolledMethods();
}
private async Task EnrollAuthenticatorApp()
{
var command = new InitiateAuthenticatorAppEnrollmentCommand(userId: CurrentUser.Id);
var result = await CommandRunner.ExecuteAsync(command);
QrCodeImageUrl = result.QrCodeDataUrl;
EnrollmentSecret = result.ManualEntryKey;
ShowQrCode = true;
}
private async Task VerifyAuthenticatorApp()
{
var command = new EnrollAuthenticatorAppCommand(
userId: CurrentUser.Id,
code: VerificationCode,
name: "My Authenticator");
var result = await CommandRunner.ExecuteAsync(command);
if (result.Success)
{
ShowQrCode = false;
await LoadEnrolledMethods();
}
}
// ... other methods
}
MFA Best Practices
-
Don't Make MFA Mandatory for All Users (too friction-heavy)
- Require for admins and sensitive roles
- Offer optional enrollment for regular users
- Make mandatory only for high-risk operations
-
Provide Multiple MFA Options
- Email for accessibility
- Apps for security-conscious users
- Devices for highest security
-
Backup Codes
- Provide after authenticator enrollment
- Store securely (printed or saved)
- Allow admin code generation
-
Recovery Strategies
- Account recovery via email or phone
- Admin MFA reset for locked-out users
- Alternative contact methods
-
Monitor MFA Metrics
- Enrollment rates per user segment
- Code generation frequency
- Failed validation patterns
Troubleshooting MFA
User Can't Receive Email Codes
- Check user's contact email address
- Verify email delivery service is running
- Check spam folder for test emails
- Review email thresholds in configuration
Authenticator App Shows Wrong Code
- Ensure device time is synchronized (NTP)
- Try code from previous/next 30-second window
- Re-enroll if persistent
- Check app settings (time-based is required)
FIDO2 Device Not Recognized
- Check browser support (passwordless sign-in must be enabled)
- Verify USB connection (for physical keys)
- Ensure WebAuthn API is available
- Try on different browser or device
Users Locked Out of MFA
- Provide backup codes if available
- Admin can revoke all MFA methods
- User can call support for recovery
- Direct recovery email link w/ password reset
Next Steps
- User Management - Create users and manage accounts
- External Authentication - Entra ID setup