mfa

package
v0.5.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package mfa implements the second authentication factor for password (`db`) logins: TOTP authenticator apps, WebAuthn credentials (passkeys and hardware security keys) and single-use recovery codes.

Federated logins (SAML/OIDC) are out of scope — the identity provider owns the factor policy there. Service users are out of scope too: they authenticate with a long-lived token, not an interactive login.

Index

Constants

View Source
const (
	// PurposeLogin — first factor passed, waiting for the second.
	PurposeLogin string = "login"
	// PurposeEnroll — first factor passed but the deployment requires MFA
	// and this user has none, so they must enroll before getting a session.
	PurposeEnroll string = "enroll"
	// PurposeRegister — an authenticated user is registering a new WebAuthn
	// credential from their profile.
	PurposeRegister string = "register"
)

Challenge purposes.

View Source
const ChallengeTTL = 5 * time.Minute

ChallengeTTL bounds how long a half-finished login may sit. Long enough to find a phone and read a code, short enough that an abandoned challenge is not a standing invitation.

View Source
const RecoveryCodeCount = 10

RecoveryCodeCount is how many single-use codes are handed out when a user enrolls (and whenever they regenerate the set). Ten is enough to print or stash in a password manager without becoming a liability if the sheet leaks.

Variables

View Source
var (
	// ErrNotEnrolled means the user has no usable second factor.
	ErrNotEnrolled = errors.New("mfa: user is not enrolled")
	// ErrInvalidCode covers a wrong, expired or already-used code.
	ErrInvalidCode = errors.New("mfa: invalid code")
	// ErrChallenge covers an unknown, expired or already-consumed challenge.
	ErrChallenge = errors.New("mfa: invalid challenge")
)

Errors callers match on to pick an HTTP status. They are deliberately coarse: the login path must not tell an attacker whether the username, the challenge or the code was the part that did not check out.

Functions

func Code

func Code(secret string, step int64) (string, error)

Code computes the TOTP value for a secret at a given time step.

func GenerateRecoveryCodes

func GenerateRecoveryCodes() ([]string, error)

GenerateRecoveryCodes returns fresh plaintext recovery codes, formatted in four dash-separated groups for legibility. They are shown to the user once and only their hashes are stored.

func GenerateSecret

func GenerateSecret() (string, error)

GenerateSecret returns a new base32-encoded TOTP shared secret.

func HashRecoveryCode

func HashRecoveryCode(code string) string

HashRecoveryCode hashes a code for storage.

SHA-256 rather than bcrypt on purpose: recovery codes are ~99 bits of machine-generated randomness, not a user-chosen password, so there is no dictionary to slow an attacker down against — and verification has to walk every unused code for the user, which at bcrypt cost 12 would put a multi-second delay on the login path.

func MatchRecoveryCode

func MatchRecoveryCode(code, hash string) bool

MatchRecoveryCode compares in constant time.

func NormalizeRecoveryCode

func NormalizeRecoveryCode(code string) string

NormalizeRecoveryCode makes user input comparable: uppercase, no spaces or dashes, so "abcde-fghij" and "ABCDEFGHIJ" both work.

func ProvisioningURI

func ProvisioningURI(issuer, account, secret string) string

ProvisioningURI builds the otpauth:// URI that is encoded into the QR code during enrollment. `issuer` labels the deployment in the authenticator app and `account` identifies the user within it.

func QRDataURI

func QRDataURI(uri string) (string, error)

QRDataURI renders a provisioning URI as a PNG data URI, so the SPA can show the enrollment QR code with a plain <img> and no client-side QR library.

func Step

func Step(t time.Time) int64

Step returns the RFC 6238 time step a given instant falls into. Steps are persisted per user so a code cannot be replayed inside its validity window.

func Validate

func Validate(secret, code string, now time.Time, lastStep int64) (int64, bool)

Validate checks a user-supplied code against the secret around `now`, accepting ±totpSkew steps of drift. `lastStep` is the most recent step this user already authenticated with; steps at or below it are refused so a code observed over someone's shoulder cannot be used a second time while it is still in its window.

It returns the step the code matched, which the caller MUST persist as the new lastStep for the user.

Types

type Challenge

type Challenge struct {
	gorm.Model
	ChallengeID string `gorm:"uniqueIndex"`
	Username    string `gorm:"index"`
	Purpose     string
	// SessionData is the go-webauthn session blob (JSON) for ceremonies
	// that have one; empty for TOTP and recovery-code challenges.
	SessionData string `gorm:"type:text"`
	// PendingSecret carries a TOTP secret through a forced enrollment at
	// login, before the user has proven they can generate codes from it.
	PendingSecret string
	// ExpHours is the token lifetime the client asked for in the first
	// step, so the second step can honor it.
	ExpHours   int
	ExpiresAt  time.Time
	ConsumedAt *time.Time
}

Challenge ties the two halves of a login (or a WebAuthn ceremony) together. The first factor succeeding does not create a session; it creates one of these, and only presenting a second factor against it mints a token.

Rows are single-use and short-lived, which is what makes a stolen challenge id worthless on its own: it still requires the second factor.

func (Challenge) TableName

func (Challenge) TableName() string

type Credential

type Credential struct {
	gorm.Model
	Username string `gorm:"index"`
	// Name is the operator-supplied label ("YubiKey 5C", "iPhone").
	Name string
	// CredentialID is stored base64url-encoded rather than as raw bytes:
	// a unique index on a blob column needs a prefix length on MySQL, and
	// the SPA needs the string form anyway to address the row.
	CredentialID    string `gorm:"uniqueIndex"`
	PublicKey       []byte
	AttestationType string
	AAGUID          []byte
	SignCount       uint32
	// CloneWarning is set when an authenticator's signature counter goes
	// backwards, which suggests a cloned credential. Kept as a flag rather
	// than a hard failure — some authenticators legitimately keep the
	// counter at zero.
	CloneWarning   bool
	Transports     string
	BackupEligible bool
	BackupState    bool
	LastUsedAt     *time.Time
}

Credential is one registered WebAuthn authenticator: a passkey, a platform authenticator (Touch ID, Windows Hello) or a roaming security key.

func (Credential) TableName

func (Credential) TableName() string

type Manager

type Manager struct {
	DB *gorm.DB
}

Manager owns the MFA tables.

func NewManager

func NewManager(backend *gorm.DB) *Manager

NewManager initializes the manager and migrates the MFA tables.

func (*Manager) AddCredential

func (m *Manager) AddCredential(cred *Credential) error

AddCredential stores a freshly registered authenticator.

func (*Manager) BeginTOTP

func (m *Manager) BeginTOTP(username string) (string, error)

BeginTOTP starts (or restarts) an enrollment and returns the shared secret. The row stays unconfirmed until ConfirmTOTP succeeds, so calling this on an account that already has TOTP does not disable the working factor: the existing confirmed row is kept until the new one is confirmed.

func (*Manager) CompleteEnrollment

func (m *Manager) CompleteEnrollment(username, secret, code string) ([]string, error)

CompleteEnrollment confirms a TOTP secret that was carried on a challenge rather than stored against the user — the forced-enrollment-at-login path, where nothing should be written until the user proves the secret works.

func (*Manager) ConfirmTOTP

func (m *Manager) ConfirmTOTP(username, code string) ([]string, error)

ConfirmTOTP completes an enrollment by checking a code generated from the pending secret, and returns a fresh set of recovery codes.

func (*Manager) ConsumeChallenge

func (m *Manager) ConsumeChallenge(id string) error

ConsumeChallenge marks a challenge used. It returns ErrChallenge if another request got there first, so a challenge can never mint two sessions.

func (*Manager) Credentials

func (m *Manager) Credentials(username string) ([]Credential, error)

Credentials lists the WebAuthn authenticators registered by a user.

func (*Manager) DeleteCredential

func (m *Manager) DeleteCredential(username, credentialID string) error

DeleteCredential removes one authenticator belonging to the user.

func (*Manager) DeleteUser

func (m *Manager) DeleteUser(username string) error

DeleteUser removes every factor belonging to a user. Called when the user row itself is deleted so credentials do not outlive the account.

func (*Manager) DisableTOTP

func (m *Manager) DisableTOTP(username string) error

DisableTOTP removes the authenticator factor. Recovery codes are dropped with it when no WebAuthn credential is left, since they would otherwise be a standalone bypass of a factor the user believes they turned off.

func (*Manager) Enabled

func (m *Manager) Enabled(username string) bool

Enabled reports whether the user must present a second factor to log in.

func (*Manager) GetChallenge

func (m *Manager) GetChallenge(id, purpose string) (Challenge, error)

GetChallenge returns a live challenge with the expected purpose.

func (*Manager) NewChallenge

func (m *Manager) NewChallenge(username, purpose string, expHours int) (string, error)

NewChallenge stores a challenge and returns its opaque id.

func (*Manager) RegenerateRecoveryCodes

func (m *Manager) RegenerateRecoveryCodes(username string) ([]string, error)

RegenerateRecoveryCodes replaces every code for the user and returns the new plaintext set — the only time it is ever available.

func (*Manager) SetChallengeSession

func (m *Manager) SetChallengeSession(id, sessionData, pendingSecret string) error

SetChallengeSession attaches WebAuthn ceremony state (or a pending TOTP secret) to a challenge between its begin and finish steps.

func (*Manager) Status

func (m *Manager) Status(username string) (Status, error)

Status returns the enrolled factors for a user.

func (*Manager) TouchCredential

func (m *Manager) TouchCredential(username, credentialID string, signCount uint32, cloneWarning bool) error

TouchCredential records a successful assertion.

func (*Manager) VerifyRecoveryCode

func (m *Manager) VerifyRecoveryCode(username, code string) error

VerifyRecoveryCode consumes one unused recovery code.

func (*Manager) VerifyTOTP

func (m *Manager) VerifyTOTP(username, code string) error

VerifyTOTP checks a login-time code and burns its time step.

type RecoveryCode

type RecoveryCode struct {
	gorm.Model
	Username string `gorm:"index"`
	CodeHash string
	UsedAt   *time.Time
}

RecoveryCode is one single-use code. Rows are kept after use so the UI can report how many are left and the audit trail shows a code was burned.

func (RecoveryCode) TableName

func (RecoveryCode) TableName() string

type Status

type Status struct {
	TOTPEnabled       bool         `json:"totp_enabled"`
	Credentials       []Credential `json:"credentials"`
	RecoveryCodesLeft int          `json:"recovery_codes_left"`
}

Status is what the profile page and the login flow need to know about a user's factors. It never carries the TOTP secret.

type TOTPEnrollment

type TOTPEnrollment struct {
	gorm.Model
	Username string `gorm:"uniqueIndex"`
	// Secret is the base32 shared secret. It is as sensitive as a password
	// hash — anyone holding it can mint valid codes — so it is never
	// returned by the API after enrollment completes.
	Secret    string
	Confirmed bool
	// LastStep is the most recent RFC 6238 step this user authenticated
	// with, so the same code cannot be replayed inside its window.
	LastStep int64
}

TOTPEnrollment is the authenticator-app factor for one user. A row exists from the moment enrollment starts; Confirmed flips to true only once the user has echoed back a valid code, so an abandoned enrollment never locks anyone out.

func (TOTPEnrollment) TableName

func (TOTPEnrollment) TableName() string

type WebAuthn

type WebAuthn struct {
	// contains filtered or unexported fields
}

WebAuthn wraps go-webauthn with the storage in this package. It covers both roaming security keys (YubiKey and friends) and passkeys — from the relying party's side they are the same ceremony, they differ only in where the private key lives.

func NewWebAuthn

func NewWebAuthn(mgr *Manager, rpID, displayName string, origins []string) (*WebAuthn, error)

NewWebAuthn builds the relying party. rpID is the registrable domain the credentials are scoped to ("osctrl.example.com"), and origins are the exact origins the SPA is served from ("https://osctrl.example.com"). Both must match what the browser sees or every ceremony fails at the browser, before it ever reaches us.

func (*WebAuthn) BeginLogin

func (w *WebAuthn) BeginLogin(username string) (*protocol.CredentialAssertion, string, error)

BeginLogin returns the assertion options for navigator.credentials.get() and the session blob for the finish step.

func (*WebAuthn) BeginRegistration

func (w *WebAuthn) BeginRegistration(username string) (*protocol.CredentialCreation, string, error)

BeginRegistration returns the creation options the browser passes to navigator.credentials.create(), plus the session blob to hand back on finish. Registration is scoped to a challenge row so the ceremony state is server-side and single-use.

func (*WebAuthn) FinishLogin

func (w *WebAuthn) FinishLogin(username, sessionBlob string, response json.RawMessage) error

FinishLogin validates an assertion and records the new signature counter.

func (*WebAuthn) FinishRegistration

func (w *WebAuthn) FinishRegistration(username, name, sessionBlob string, response json.RawMessage) (*Credential, error)

FinishRegistration validates the attestation and returns the credential row to store. `name` is the operator-supplied label.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL