diff --git a/common/verification.go b/common/verification.go index 41fd3c943e7..9424f239316 100644 --- a/common/verification.go +++ b/common/verification.go @@ -1,10 +1,12 @@ package common import ( + "crypto/subtle" "strings" "sync" "time" + "github.com/go-redis/redis/v8" "github.com/google/uuid" ) @@ -18,6 +20,8 @@ const ( PasswordResetPurpose = "r" ) +const verificationRedisPrefix = "verify_code:" + var verificationMutex sync.Mutex var verificationMap map[string]verificationValue var verificationMapMaxSize = 10 @@ -32,7 +36,18 @@ func GenerateVerificationCode(length int) string { return code[:length] } +func verificationRedisKey(key, purpose string) string { + return verificationRedisPrefix + purpose + ":" + key +} + func RegisterVerificationCodeWithKey(key string, code string, purpose string) { + if RedisEnabled { + ttl := time.Duration(VerificationValidMinutes) * time.Minute + if err := RedisSet(verificationRedisKey(key, purpose), code, ttl); err != nil { + SysError("RegisterVerificationCodeWithKey: redis set failed: " + err.Error()) + } + return + } verificationMutex.Lock() defer verificationMutex.Unlock() verificationMap[purpose+key] = verificationValue{ @@ -45,6 +60,16 @@ func RegisterVerificationCodeWithKey(key string, code string, purpose string) { } func VerifyCodeWithKey(key string, code string, purpose string) bool { + if RedisEnabled { + stored, err := RedisGet(verificationRedisKey(key, purpose)) + if err != nil { + if err != redis.Nil { + SysError("VerifyCodeWithKey: redis get failed: " + err.Error()) + } + return false + } + return subtle.ConstantTimeCompare([]byte(stored), []byte(code)) == 1 + } verificationMutex.Lock() defer verificationMutex.Unlock() value, okay := verificationMap[purpose+key] @@ -56,6 +81,12 @@ func VerifyCodeWithKey(key string, code string, purpose string) bool { } func DeleteKey(key string, purpose string) { + if RedisEnabled { + if err := RedisDelKey(verificationRedisKey(key, purpose)); err != nil { + SysError("DeleteKey: redis del failed: " + err.Error()) + } + return + } verificationMutex.Lock() defer verificationMutex.Unlock() delete(verificationMap, purpose+key) diff --git a/common/verification_test.go b/common/verification_test.go new file mode 100644 index 00000000000..a423c261de3 --- /dev/null +++ b/common/verification_test.go @@ -0,0 +1,38 @@ +package common + +import "testing" + +// Exercise the in-process map fallback path that's used when Redis is +// disabled. The Redis path is covered by live testing. +func TestVerifyCodeWithKey_MemoryFallback(t *testing.T) { + prevRedis := RedisEnabled + RedisEnabled = false + t.Cleanup(func() { RedisEnabled = prevRedis }) + + const key = "user@example.com" + const code = "abc123" + + if VerifyCodeWithKey(key, code, EmailVerificationPurpose) { + t.Fatalf("expected false before code is registered") + } + + RegisterVerificationCodeWithKey(key, code, EmailVerificationPurpose) + + if !VerifyCodeWithKey(key, code, EmailVerificationPurpose) { + t.Fatalf("expected true after registration") + } + + if VerifyCodeWithKey(key, "wrong", EmailVerificationPurpose) { + t.Fatalf("expected false for wrong code") + } + + // Different purpose namespace must not collide. + if VerifyCodeWithKey(key, code, PasswordResetPurpose) { + t.Fatalf("purposes must be isolated") + } + + DeleteKey(key, EmailVerificationPurpose) + if VerifyCodeWithKey(key, code, EmailVerificationPurpose) { + t.Fatalf("expected false after DeleteKey") + } +}