fix(mfa): make ticket attempt count atomic via Redis INCR

This commit is contained in:
2026-09-16 08:37:07 +00:00
parent 9c60adc836
commit dfcfd1d3e2
2 changed files with 47 additions and 15 deletions
+33 -15
View File
@@ -57,6 +57,14 @@ func attemptsLeft(attempts int) int {
return maxTicketAttempts - attempts
}
// attemptsKey is the counter backing a ticket's brute-force cap. It is a
// separate key rather than a field on the ticket JSON so INCR can make the
// count atomic: two requests racing on the same ticket must each cost one
// attempt, not both read the same count and both write count+1.
func attemptsKey(id string) string {
return ticketPrefix + id + ":attempts"
}
func CreateTicket(ctx context.Context, t *Ticket) (string, error) {
id, err := randomHex(32)
if err != nil {
@@ -87,31 +95,41 @@ func LoadTicket(ctx context.Context, id string) (*Ticket, error) {
return &t, nil
}
// FailTicket records a wrong code and returns how many attempts remain. At zero
// the ticket is destroyed rather than left to time out.
// FailTicket records a wrong code and returns how many attempts remain. The
// count is kept in its own INCR-backed key rather than the ticket JSON: a
// read-modify-write on the JSON lets requests racing on the same ticket all
// read the same count and all write count+1, which bypasses the cap instead
// of costing one attempt each. At zero the ticket is destroyed rather than
// left to time out.
func FailTicket(ctx context.Context, id string) (int, error) {
t, err := LoadTicket(ctx, id)
// Confirms the ticket exists first, so a missing/expired/destroyed ticket
// still answers with the one indistinguishable ErrTicketExpired rather
// than incrementing a counter for an id nobody holds.
if _, err := LoadTicket(ctx, id); err != nil {
return 0, err
}
key := attemptsKey(id)
n, err := rdb.Incr(ctx, key).Result()
if err != nil {
return 0, err
}
t.Attempts++
if attemptsLeft(t.Attempts) == 0 {
if n == 1 {
// Only the creator of the counter sets its expiry, so a later
// increment never extends it past the ticket's own window.
if err := rdb.Expire(ctx, key, ticketTTL).Err(); err != nil {
return 0, err
}
}
left := attemptsLeft(int(n))
if left == 0 {
_ = DeleteTicket(ctx, id)
return 0, nil
}
data, err := json.Marshal(t)
if err != nil {
return 0, err
}
// KEEPTTL: a wrong code must not extend the five-minute window.
if err := rdb.Set(ctx, ticketPrefix+id, data, redis.KeepTTL).Err(); err != nil {
return 0, err
}
return attemptsLeft(t.Attempts), nil
return left, nil
}
func DeleteTicket(ctx context.Context, id string) error {
return rdb.Del(ctx, ticketPrefix+id).Err()
return rdb.Del(ctx, ticketPrefix+id, attemptsKey(id)).Err()
}
func SetPendingCookie(c *gin.Context, id string) {
+14
View File
@@ -19,6 +19,20 @@ func TestAttemptsLeftCountsDownAndHitsZero(t *testing.T) {
}
}
// attemptsKey must derive deterministically from the ticket id and stay
// distinct from the ticket's own key, since FailTicket relies on INCR against
// it being the sole writer of the attempt count.
func TestAttemptsKeyIsDerivedFromTicketID(t *testing.T) {
got := attemptsKey("abc123")
want := "km:mfa:abc123:attempts"
if got != want {
t.Errorf("attemptsKey(%q) = %q, want %q", "abc123", got, want)
}
if attemptsKey("abc123") == ticketPrefix+"abc123" {
t.Error("attempts key must not collide with the ticket's own key")
}
}
// An enrol-only ticket exists because the instance requires MFA the user does
// not have. It must not satisfy a verification endpoint, and a verification
// ticket must not reach the enrolment endpoints - each would skip the other's