feat(mfa): account MFA management, passkey registration and step-up

This commit is contained in:
2026-09-16 09:08:24 +00:00
parent bd0639acfa
commit f87626cf17
7 changed files with 640 additions and 0 deletions
+51
View File
@@ -377,6 +377,57 @@ func IssueRecoveryCodes(instanceID, userID string) ([]string, error) {
return plain, nil
}
var ErrMFARequiredByPolicy = errors.New("this instance requires multi-factor authentication; add another factor before removing this one")
// canRemoveFactor is pure so the rule is testable without a database.
// removing is FactorTOTP or FactorWebAuthn, and for a passkey it means one of
// the counted passkeys.
func canRemoveFactor(requireMFA, hasTOTP bool, passkeys int, removing string) error {
if !requireMFA {
return nil
}
remaining := 0
if hasTOTP && removing != FactorTOTP {
remaining++
}
switch removing {
case FactorWebAuthn:
remaining += passkeys - 1
default:
remaining += passkeys
}
if remaining > 0 {
return nil
}
return ErrMFARequiredByPolicy
}
// CheckCanRemoveFactor reads the current state and applies the rule.
func CheckCanRemoveFactor(instanceID, userID, removing string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return err
}
return canRemoveFactor(RequireMFAForInstance(instanceID), m != nil && m.TOTPConfirmedAt != nil, int(passkeys), removing)
}
// RemoveTOTP clears only the TOTP factor, leaving passkeys and recovery codes.
func RemoveTOTP(instanceID, userID string) error {
ctx, cancel := mfaCtx()
defer cancel()
_, err := db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$unset": bson.M{"totp_secret_enc": "", "totp_confirmed_at": "", "totp_pending_enc": ""},
"$set": bson.M{"updated_at": time.Now()},
})
return err
}
// RecoveryCodesRemaining counts unused codes for the account page.
func RecoveryCodesRemaining(m *models.UserMFA) int {
if m == nil {
@@ -0,0 +1,31 @@
package services
import "testing"
// Removing a factor under an MFA policy must leave at least one behind, or the
// user locks themselves out of an instance that will then demand enrolment
// they cannot complete without signing in.
func TestCanRemoveFactor(t *testing.T) {
cases := []struct {
name string
requireMFA bool
totp bool
passkeys int
removing string
wantRefusal bool
}{
{"policy off, last factor", false, true, 0, FactorTOTP, false},
{"policy on, totp plus passkey, drop totp", true, true, 1, FactorTOTP, false},
{"policy on, last totp", true, true, 0, FactorTOTP, true},
{"policy on, last passkey", true, false, 1, FactorWebAuthn, true},
{"policy on, two passkeys, drop one", true, false, 2, FactorWebAuthn, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := canRemoveFactor(tc.requireMFA, tc.totp, tc.passkeys, tc.removing)
if tc.wantRefusal != (err != nil) {
t.Fatalf("canRemoveFactor refusal = %v, want %v", err != nil, tc.wantRefusal)
}
})
}
}