feat(mfa): account MFA management, passkey registration and step-up
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user