fix(auth): final-review MFA fixes F1 F2 F3 F5 F7 and drop Ticket.Attempts
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 5m51s

- OIDC sessions minted through saveSignIn with AMR oidc and StepUpAt
- passwordless passkey uses ValidateDiscoverableLogin with owner-handle check
- TOTP replay guard burns the matched step, not the current one
- enrol-only tickets refused once the user already has a factor
- bootstrap owner session minted through mintSession
This commit is contained in:
2026-09-16 14:26:42 +00:00
parent dcdfd3ce52
commit 19383abaf8
11 changed files with 171 additions and 38 deletions
+25 -11
View File
@@ -300,13 +300,28 @@ func VerifyTOTPCode(instanceID, userID, code string) error {
if err != nil {
return err
}
valid, err := totp.ValidateCustom(code, secret, time.Now(), totp.ValidateOpts{
Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err != nil || !valid {
step, ok := matchTOTPStep(code, secret, time.Now())
if !ok {
return ErrBadCode
}
return burnTOTPCode(userID, code)
return burnTOTPStep(userID, step)
}
// matchTOTPStep returns the 30-second time step the code was generated for,
// trying the current step and one either side (the accepted skew). The replay
// guard must burn this step, not the current one: burning the current step
// would let a code accepted as s-1 or s+1 be accepted again one step later.
func matchTOTPStep(code, secret string, now time.Time) (int64, bool) {
cur := now.Unix() / 30
for _, step := range []int64{cur - 1, cur, cur + 1} {
want, err := totp.GenerateCodeCustom(secret, time.Unix(step*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err == nil && subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return step, true
}
}
return 0, false
}
// ClearMFA removes every factor. Used by an owner or admin reset.
@@ -367,18 +382,17 @@ func RequireMFAForInstance(instanceID string) bool {
return models.RequireMFA(s)
}
// burnTOTPCode makes a step single-use for 90 seconds - longer than the +-1
// burnTOTPStep 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
// Redis key name would be a currently-valid credential readable by anything
// that can list keys. Redis is already required for sessions.
func burnTOTPCode(userID, code string) error {
// matched, never on the code itself: a raw code sitting in a Redis key name
// would be a currently-valid credential readable by anything that can list
// keys. Redis is already required for sessions.
func burnTOTPStep(userID string, step int64) error {
if RedisClient == nil {
return nil
}
ctx, cancel := mfaCtx()
defer cancel()
step := time.Now().Unix() / 30
key := "km:totp:" + userID + ":" + strconv.FormatInt(step, 10)
ok, err := RedisClient.SetNX(ctx, key, 1, 90*time.Second).Result()
if err != nil {
+29
View File
@@ -6,6 +6,8 @@ import (
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
func TestGenerateRecoveryCodesReturnsTenUniqueHashedCodes(t *testing.T) {
@@ -84,3 +86,30 @@ func TestHasFactorRequiresConfirmedTOTPOrAPasskey(t *testing.T) {
})
}
}
func TestMatchTOTPStepReturnsTheStepTheCodeWasGeneratedFor(t *testing.T) {
const secret = "JBSWY3DPEHPK3PXP"
now := time.Unix(1_800_000_015, 0) // mid-step, well clear of a boundary
cur := now.Unix() / 30
for _, off := range []int64{-1, 0, 1} {
code, err := totp.GenerateCodeCustom(secret, time.Unix((cur+off)*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
t.Fatal(err)
}
step, ok := matchTOTPStep(code, secret, now)
if !ok || step != cur+off {
t.Fatalf("offset %d: got step %d ok=%v, want %d", off, step, ok, cur+off)
}
}
far, _ := totp.GenerateCodeCustom(secret, time.Unix((cur+5)*30, 0), totp.ValidateOpts{
Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if _, ok := matchTOTPStep(far, secret, now); ok {
t.Fatal("a code from five steps ahead must not match")
}
if _, ok := matchTOTPStep("000000x", secret, now); ok {
t.Fatal("a malformed code must not match")
}
}