fix: add TTL-based expiry and cleanup for used TOTP passcode entries

Store a unix timestamp instead of a boolean, and treat entries older
than 90 seconds as expired. A background goroutine lazily cleans up
expired keys after each successful validation to prevent unbounded
growth in the keyvalue store.
This commit is contained in:
kolaente
2026-03-20 11:46:07 +01:00
committed by kolaente
parent acafa6db10
commit 0f98c19ab6

View File

@@ -20,6 +20,7 @@ import (
"fmt"
"image"
"strconv"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
@@ -138,25 +139,50 @@ func ValidateTOTPPasscode(s *xorm.Session, passcode *TOTPPasscode) (t *TOTP, err
return nil, ErrInvalidTOTPPasscode{Passcode: passcode.Passcode}
}
// Prevent passcode reuse: check if this passcode was already used
// Prevent passcode reuse within the validity window.
// Store the timestamp when the passcode was used; treat entries older than
// 90 seconds (30s TOTP window + clock skew) as expired.
const totpUsedTTL = 90 * time.Second
usedKey := fmt.Sprintf("totp_used_%s_%s", strconv.FormatInt(passcode.User.ID, 10), passcode.Passcode)
_, exists, err := keyvalue.Get(usedKey)
val, exists, err := keyvalue.Get(usedKey)
if err != nil {
return nil, err
}
if exists {
return nil, ErrTOTPPasscodeUsed{}
if usedAt, ok := val.(int64); ok && time.Since(time.Unix(usedAt, 0)) < totpUsedTTL {
return nil, ErrTOTPPasscodeUsed{}
}
// Entry expired — allow reuse, overwrite below
}
// Mark this passcode as used
err = keyvalue.Put(usedKey, true)
// Mark this passcode as used with the current timestamp
err = keyvalue.Put(usedKey, time.Now().Unix())
if err != nil {
return nil, err
}
// Lazily clean up expired entries to prevent unbounded growth
go cleanupExpiredTOTPKeys(totpUsedTTL)
return
}
func cleanupExpiredTOTPKeys(ttl time.Duration) {
keys, err := keyvalue.ListKeys("totp_used_")
if err != nil {
return
}
for _, key := range keys {
val, exists, err := keyvalue.Get(key)
if err != nil || !exists {
continue
}
if usedAt, ok := val.(int64); ok && time.Since(time.Unix(usedAt, 0)) >= ttl {
_ = keyvalue.Del(key)
}
}
}
// GetTOTPQrCodeForUser returns a qrcode for a user's totp setting
func GetTOTPQrCodeForUser(s *xorm.Session, user *User) (qrcode image.Image, err error) {
t, err := GetTOTPForUser(s, user)