Skip to main content

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 emailMfaTokenGenerations limit (default: 3)
  • After 3 generations, user must complete authentication or password reset

Recovery

If user can't access email:

  1. Admin can force password reset via console
  2. User completes password reset via email link from admin
  3. Email MFA is cleared during reset
  4. 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

  1. User signs in with MFA
  2. "Remember this device for 60 days" checkbox appears
  3. User checks and completes MFA
  4. Device is trusted for 60 days
  5. 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

  1. 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
  2. Provide Multiple MFA Options

    • Email for accessibility
    • Apps for security-conscious users
    • Devices for highest security
  3. Backup Codes

    • Provide after authenticator enrollment
    • Store securely (printed or saved)
    • Allow admin code generation
  4. Recovery Strategies

    • Account recovery via email or phone
    • Admin MFA reset for locked-out users
    • Alternative contact methods
  5. 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