diff --git a/pkg/user/totp.go b/pkg/user/totp.go index 31235cce5..bf50f5dc5 100644 --- a/pkg/user/totp.go +++ b/pkg/user/totp.go @@ -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)