diff --git a/backend/app/services.go b/backend/app/services.go index d6b7bd2..80538bc 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -112,6 +112,7 @@ func NewServices( RoleRepository: repositories.Role, CompanyRepository: repositories.Company, PasswordHasher: utilities.PasswordHasher, + TOTPReplayCache: service.NewTOTPReplayCache(), } recipient := &service.Recipient{ Common: common, diff --git a/backend/service/totpReplayCache.go b/backend/service/totpReplayCache.go new file mode 100644 index 0000000..2402b12 --- /dev/null +++ b/backend/service/totpReplayCache.go @@ -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() + } +} diff --git a/backend/service/user.go b/backend/service/user.go index 11e9d0b..600dc90 100644 --- a/backend/service/user.go +++ b/backend/service/user.go @@ -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 }