feat(mfa): second-factor sign-in with TOTP and recovery codes

This commit is contained in:
2026-09-16 08:39:48 +00:00
parent dfcfd1d3e2
commit 2d75832ceb
6 changed files with 254 additions and 7 deletions
+45
View File
@@ -290,6 +290,51 @@ func ClearMFA(instanceID, userID string) error {
return err
}
// UseRecoveryCode consumes one unused code, marking it used by index so a
// concurrent second attempt with the same code finds it spent.
func UseRecoveryCode(instanceID, userID, input string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
if m == nil || len(m.RecoveryCodes) == 0 {
return ErrNoMFA
}
idx, ok := ConsumeRecoveryCode(m.RecoveryCodes, input, time.Now())
if !ok {
return ErrBadCode
}
ctx, cancel := mfaCtx()
defer cancel()
now := time.Now()
res, err := db.Col("user_mfa").UpdateOne(ctx,
bson.M{
"instance_id": instanceID, "user_id": userID,
"recovery_codes." + strconv.Itoa(idx) + ".used_at": bson.M{"$exists": false},
},
bson.M{"$set": bson.M{
"recovery_codes." + strconv.Itoa(idx) + ".used_at": now,
"updated_at": now,
}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrBadCode
}
return nil
}
// RequireMFAForInstance reads the policy, defaulting to off on any error: a
// database blip must not lock an entire instance out of its own control plane.
func RequireMFAForInstance(instanceID string) bool {
s, err := GetSettings(instanceID)
if err != nil {
return false
}
return models.RequireMFA(s)
}
// burnTOTPCode makes a step single-use for 90 seconds - longer than the +-1
// step window it could still validate in. Keyed on the time step the code
// was accepted against, never on the code itself: a raw code sitting in a