Skip to content

Two-Factor Authentication

Modgud supports four 2FA methods, all implemented in the Authentication slice. Any number of methods can be active per user.

MethodServiceStorage
TOTPASP.NET Core Identity DefaultTokenProvidersUserSecurityData.AuthenticatorKey
Email OTPEmailOtpServiceEmailOtpChallenge (ephemeral)
Passkey/FIDO2Fido2NetLibStoredPasskeyCredential
Magic LinkMagicLinkServiceMagicLinkChallenge (ephemeral)

Plus recovery codes as a last resort.

Login flow with 2FA

On the first login step the Modgud.2FA cookie is set (lifetime: 5 min), holding the UserId between step 1 and step 2. Only a successful second step issues the full Modgud.Auth cookie.

TOTP (authenticator apps)

Standard RFC 6238, compatible with Google Authenticator, Authy, Microsoft Authenticator, etc.

Setup

http
POST /api/account/mfa/setup

→ Generates a fresh authenticator key (32 bytes Base32) via UserManager.ResetAuthenticatorKeyAsync(). Returns:

json
{
  "sharedKey": "ABCD EFGH IJKL MNOP",
  "authenticatorUri": "otpauth://totp/Modgud:alice@example.com?secret=...&issuer=Modgud&digits=6"
}

sharedKey is formatted in groups of four for manual entry; authenticatorUri is for QR-code generation.

Activate

http
POST /api/account/mfa/enable
{ "code": "123456" }

UserManager.VerifyTwoFactorTokenAsync() checks the code; on success TwoFactorEnabled = true is set + 10 recovery codes are generated.

Deactivate

http
POST /api/account/mfa/disable
{ "code": "123456" }

→ Verifies a TOTP code once more. Resets the authenticator key.

Last 2FA at level ≥ 1

When a user removes their last 2FA method while AuthenticationMinimumLevel >= 1, SecureSetupDueAt = now is set → the user is blocked immediately (no new grace window).

Email OTP

6-digit code by email to the verified email address.

How it works

  1. Request: POST /api/account/email-otp/login/request generates a 6-digit code, hashes it with SHA-256, and stores the hash in an EmailOtpChallenge document
  2. Send: code via IEmailService.SendEmailOtpAsync()
  3. Verify: POST /api/account/email-otp/login hashes the entered code and compares it

Protection mechanisms

ProtectionImplementation
Rate limitAt least 2 min between OTP requests
Expiry10 min
Attempt limitAt most 3 verify attempts per challenge
Code never in plain textOnly SHA-256 hash stored

EmailOtpChallenge is 1:1 per UserId — requesting a new code replaces any existing challenge.

Passkey / FIDO2 / WebAuthn

Hardware keys (YubiKey) or platform authenticators (TouchID, Windows Hello). Implemented with Fido2NetLib.

Registration ceremony

http
POST /api/account/passkey/register/options

CredentialCreateOptions with:

  • ResidentKey = Preferred (for discoverable credentials → passwordless)
  • UserVerification = Preferred
  • excludeCredentials = the user's existing credentials

Challenge bytes + options JSON are stored in a Modgud.Session ASP.NET session entry (Marten DistributedMemoryCache as the session store), 5 min idle.

http
POST /api/account/passkey/register/complete
{ "attestation": {...} }

_fido2.MakeNewCredentialAsync() verifies the attestation against the stored challenge. On success a StoredPasskeyCredential is created.

Authentication ceremony

http
POST /api/account/passkey/login/options
{ "userName": "alice" }   // optional — empty = passwordless mode

AssertionOptions scoped to the user's existing credentials. With userName=null, discoverable credentials are allowed (passwordless).

http
POST /api/account/passkey/login/complete
{ "assertion": {...} }

→ Verifies the assertion via _fido2.MakeAssertionAsync(), checks SignCount against the stored value (replay protection), updates LastUsedAt.

Passwordless

POST /api/account/passkey/login/options without userName produces options with an empty AllowedCredentials list → the authenticator picks a discoverable credential. The UserId is read from the UserHandle of the assertion.

StoredPasskeyCredential

FieldPurpose
CredentialIdUnique id (Base64-encoded)
PublicKeyCOSE-format public key
UserHandleUserId in bytes (for discoverable)
SignCountReplay-protection counter
DeviceNameUser label (e.g. "YubiKey 5")
AaguidAuthenticator model id
TransportsUSB, NFC, BLE, internal
LastUsedAtAudit

Configuration

Derived in Program.cs from IServerConfiguration.PublicUrl:

csharp
builder.Services.AddFido2(options =>
{
    options.ServerDomain = publicUri.Host;
    options.ServerName = "Modgud";
    options.Origins = fido2Origins;
});

In dev, localhost:4300 and https://localhost are additionally allowed.

Single-use token by email. Two modes:

  • Self-service (POST /api/account/magic-link/request) — only when IMagicLinkConfiguration.Enabled AND IAuthSettings.MagicLinkSelfService are both true
  • Admin send (POST /api/admin/users/{id}/magic-link) — always available, no toggle

Clicking the link:

http
GET /api/account/magic-link/login?token=...&user=...

The backend hashes the token, compares it to MagicLinkChallenge.TokenHash, checks expiry, sets a persistent cookie (always 30 days), and redirects to the frontend.

Recovery codes

10 single-use backup codes, generated when 2FA is enabled.

  • Generated via UserManager.GenerateNewTwoFactorRecoveryCodesAsync()
  • Stored in UserSecurityData.RecoveryCodes (NOT in the event stream)
  • Each code usable only once (RedeemTwoFactorRecoveryCodeAsync())
  • Regeneration invalidates all previous codes
  • Status query: GET /api/account/mfa/statusrecoveryCodesRemaining

Security data separation

All 2FA secrets live in UserSecurityData or in separate documents — never in the event stream.

DataStorageReason
Authenticator keyUserSecurityData.AuthenticatorKeyTOTP secret
Recovery codesUserSecurityData.RecoveryCodesSingle-use secrets
Passkey credentialsStoredPasskeyCredential (separate doc)Public key + counter
Password hashUserSecurityData.PasswordHashSensitive

Security domain events store metadata only:

  • UserTwoFactorEnabled(UserId) — no key
  • UserTwoFactorDisabled(UserId) — no key
  • UserRecoveryCodesRegenerated(UserId, CodeCount) — no code
  • PasskeyCredentialRegistered(UserId, CredentialId, DeviceName) — no PublicKey

This way GDPR stream replays are safe and event streams can't be abused for credential extraction.

API endpoints

EndpointMethodPurpose
/api/account/mfa/statusGETStatus (enabled, methods, recovery-codes-remaining)
/api/account/mfa/setupPOSTGenerate authenticator key + QR URI
/api/account/mfa/enablePOSTEnable 2FA with a code
/api/account/mfa/disablePOSTDisable 2FA
/api/account/mfa/recovery-codesPOSTRegenerate recovery codes
/api/account/mfa/loginPOSTLogin step 2 with TOTP
/api/account/mfa/recovery-loginPOSTLogin with recovery code
/api/account/email-otp/statusGETEmail-OTP status
/api/account/email-otp/login/requestPOSTRequest email OTP
/api/account/email-otp/loginPOSTLogin with email OTP
/api/account/passkey/register/optionsPOSTPasskey register options
/api/account/passkey/register/completePOSTComplete passkey registration
/api/account/passkey/login/optionsPOSTPasskey login options
/api/account/passkey/login/completePOSTComplete passkey login
/api/account/passkey/credentialsGETList own passkeys
/api/account/passkey/credentials/{id}DELETEDelete a passkey
/api/account/magic-link/requestPOSTRequest a self-service magic link
/api/account/magic-link/loginGETMagic-link login

Released under the Apache-2.0 License.