fix totp code not replayable

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-04-29 19:52:13 +02:00
parent c035e2b117
commit 8b955c4742
3 changed files with 76 additions and 2 deletions
+1
View File
@@ -112,6 +112,7 @@ func NewServices(
RoleRepository: repositories.Role,
CompanyRepository: repositories.Company,
PasswordHasher: utilities.PasswordHasher,
TOTPReplayCache: service.NewTOTPReplayCache(),
}
recipient := &service.Recipient{
Common: common,
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"sync"
"time"
)
// totpWindow is the lifetime of a used-token entry.
// pquerna/otp totp.Validate uses Skew:1 (current ±1 period × 30s = 90s total window).
const totpWindow = 90 * time.Second
type TOTPReplayCache struct {
mu sync.Mutex
entries map[string]time.Time // "userID:token" -> usedAt
}
func NewTOTPReplayCache() *TOTPReplayCache {
c := &TOTPReplayCache{entries: make(map[string]time.Time)}
go c.runCleanup()
return c
}
func (c *TOTPReplayCache) key(userID, token string) string {
return userID + ":" + token
}
// isUsed reports whether token has already been consumed for userID within the window.
func (c *TOTPReplayCache) isUsed(userID, token string) bool {
c.mu.Lock()
defer c.mu.Unlock()
usedAt, ok := c.entries[c.key(userID, token)]
if !ok {
return false
}
if time.Since(usedAt) > totpWindow {
delete(c.entries, c.key(userID, token))
return false
}
return true
}
// markUsed records token as consumed for userID.
func (c *TOTPReplayCache) markUsed(userID, token string) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[c.key(userID, token)] = time.Now()
}
func (c *TOTPReplayCache) runCleanup() {
ticker := time.NewTicker(totpWindow)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for k, usedAt := range c.entries {
if now.Sub(usedAt) > totpWindow {
delete(c.entries, k)
}
}
c.mu.Unlock()
}
}
+13 -2
View File
@@ -37,8 +37,9 @@ type User struct {
UserRepository *repository.User
RoleRepository *repository.Role
CompanyRepository *repository.Company
PasswordVerifier *password.Argon2Verifier
PasswordHasher *password.Argon2Hasher
PasswordVerifier *password.Argon2Verifier
PasswordHasher *password.Argon2Hasher
TOTPReplayCache *TOTPReplayCache
}
// Create creates a new user
@@ -708,11 +709,16 @@ func (u *User) SetupCheckTOTP(
}
// verify the token
u.Logger.Debug("verifying TOTP")
if u.TOTPReplayCache.isUsed(userID.String(), token.String()) {
u.Logger.Debug("failed to verify TOTP - token already used")
return errs.ErrUserWrongTOTP
}
valid := totp.Validate(token.String(), secret)
if !valid {
u.Logger.Debug("failed to verify TOTP - invalid token")
return errs.ErrUserWrongTOTP
}
u.TOTPReplayCache.markUsed(userID.String(), token.String())
u.Logger.Debugw("Enabling MFA TOTP for user", "userID", userID)
// enable TOTP
err = u.UserRepository.EnableTOTP(
@@ -807,6 +813,10 @@ func (u *User) CheckTOTP(
userID *uuid.UUID,
token *vo.String64,
) error {
if u.TOTPReplayCache.isUsed(userID.String(), token.String()) {
u.Logger.Debug("failed to verify TOTP - token already used")
return errs.ErrUserWrongTOTP
}
// get the secret
secret, _, err := u.UserRepository.GetTOTP(
ctx,
@@ -822,6 +832,7 @@ func (u *User) CheckTOTP(
u.Logger.Debug("failed to verify TOTP - invalid token")
return errs.ErrUserWrongTOTP
}
u.TOTPReplayCache.markUsed(userID.String(), token.String())
return nil
}