fix(auth): final-review MFA fixes F1 F2 F3 F5 F7 and drop Ticket.Attempts
- 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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user