perf: sync protocol and core hardening updates

This commit is contained in:
A 2026-07-11 19:48:26 +08:00
parent 152fed3b87
commit 4390ebf5a9
283 changed files with 29231 additions and 2295 deletions

View file

@ -158,4 +158,21 @@ func TestRedisRateLimiterWindow(t *testing.T) {
if allowed || retry <= 0 {
t.Fatalf("AllowN second allowed=%v retry=%d, want limited with retry", allowed, retry)
}
// Heal a counter left without TTL by an older split INCRBY→EXPIRE writer.
// Without this branch one transient crash could permanently deny login-code
// issuance for the affected phone/auth-key limiter dimension.
orphanKey := key + ":orphan-no-ttl"
redisOrphanKey := rateLimitKey(orphanKey)
t.Cleanup(func() { _ = c.Del(ctx, redisOrphanKey).Err() })
if err := c.Set(ctx, redisOrphanKey, 100, 0).Err(); err != nil {
t.Fatalf("seed no-TTL counter: %v", err)
}
allowed, retry, err = limiter.Allow(ctx, orphanKey, 1, 5*time.Second)
if err != nil || allowed || retry <= 0 || retry > 5 {
t.Fatalf("heal no-TTL counter allowed=%v retry=%d err=%v", allowed, retry, err)
}
if ttl, err := c.PTTL(ctx, redisOrphanKey).Result(); err != nil || ttl <= 0 || ttl > 5*time.Second {
t.Fatalf("healed counter TTL=%v err=%v, want (0,5s]", ttl, err)
}
}

View file

@ -68,12 +68,39 @@ if not raw then
redis.call('DEL', KEYS[2])
return false
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table'
or tonumber(record.Version or 0) ~= tonumber(ARGV[2]) then
redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
return false
end
redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
return raw
`
const updatePhoneCodeScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then
return 0
end
redis.call('SET', KEYS[1], ARGV[1], 'KEEPTTL')
return 1
`
const deleteUndecodablePhoneCodeScript = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`
func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return err
}
code.Revision = revision
v, err := json.Marshal(code)
if err != nil {
return fmt.Errorf("marshal phone code: %w", err)
@ -113,25 +140,30 @@ func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool
}
var code store.PhoneCode
if err := json.Unmarshal(raw, &code); err != nil {
return store.PhoneCode{}, false, fmt.Errorf("unmarshal phone code: %w", err)
// Version-zero records used JSON numbers for int64 fields. Once those
// fields became quoted strings, such a record is intentionally unusable;
// compare-and-delete it so a concurrent Set successor cannot be removed.
// Its scope cannot be decoded here; a stale scope index is harmless and is
// removed by the next scoped Set, ConsumeScoped, or VerifyScoped.
if deleteErr := s.c.Eval(ctx, deleteUndecodablePhoneCodeScript, []string{codeKey(hash)}, string(raw)).Err(); deleteErr != nil {
return store.PhoneCode{}, false, fmt.Errorf("delete undecodable phone code after %v: %w", err, deleteErr)
}
return store.PhoneCode{}, false, nil
}
return code, true, nil
}
func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCode) error {
key := codeKey(hash)
ttl, err := s.c.PTTL(ctx, key).Result()
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return fmt.Errorf("redis ttl phone code: %w", err)
}
if ttl <= 0 {
return nil
return err
}
code.Revision = revision
v, err := json.Marshal(code)
if err != nil {
return fmt.Errorf("marshal phone code: %w", err)
}
if err := s.c.Set(ctx, key, v, ttl).Err(); err != nil {
if err := s.c.Eval(ctx, updatePhoneCodeScript, []string{codeKey(hash)}, string(v)).Err(); err != nil {
return fmt.Errorf("redis update phone code: %w", err)
}
return nil
@ -169,6 +201,7 @@ func (s *CodeStore) ConsumeScoped(ctx context.Context, hash string, scope store.
consumeScopedCodeScript,
[]string{codeKey(hash), codeScopeKey(scope)},
hash,
store.PhoneCodeVersionCurrent,
).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
@ -187,7 +220,7 @@ func (s *CodeStore) ConsumeScoped(ctx context.Context, hash string, scope store.
if err := json.Unmarshal([]byte(raw), &code); err != nil {
return store.PhoneCode{}, false, fmt.Errorf("unmarshal consumed phone code: %w", err)
}
if code.Scope() != scope {
if code.Version != store.PhoneCodeVersionCurrent || code.Scope() != scope {
return store.PhoneCode{}, false, fmt.Errorf("consumed phone code scope mismatch")
}
return code, true, nil

View file

@ -0,0 +1,147 @@
package redisstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
const getPhoneCodeSnapshotScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return ''
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table'
or tonumber(record.Version or 0) ~= tonumber(ARGV[1])
or (record.Revision or '') == '' then
redis.call('DEL', KEYS[1])
return ''
end
return raw
`
const compareAndUpdatePhoneCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return 0
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table'
or tonumber(record.Version or 0) ~= tonumber(ARGV[1])
or (record.Revision or '') == '' then
redis.call('DEL', KEYS[1])
return 0
end
if (record.Purpose or '') ~= '' or record.Revision ~= ARGV[2] then
return 0
end
redis.call('SET', KEYS[1], ARGV[3], 'KEEPTTL')
return 1
`
const compareAndDeletePhoneCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return 0
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table'
or tonumber(record.Version or 0) ~= tonumber(ARGV[1])
or (record.Revision or '') == '' then
redis.call('DEL', KEYS[1])
return 0
end
if (record.Purpose or '') ~= '' or record.Revision ~= ARGV[2] then
return 0
end
redis.call('DEL', KEYS[1])
return 1
`
func (s *CodeStore) GetSnapshot(ctx context.Context, hash string) (store.PhoneCodeSnapshot, bool, error) {
value, err := s.c.Eval(
ctx,
getPhoneCodeSnapshotScript,
[]string{codeKey(hash)},
store.PhoneCodeVersionCurrent,
).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return store.PhoneCodeSnapshot{}, false, nil
}
return store.PhoneCodeSnapshot{}, false, fmt.Errorf("redis get phone code snapshot: %w", err)
}
raw, ok := value.(string)
if !ok {
return store.PhoneCodeSnapshot{}, false, fmt.Errorf("redis get phone code snapshot: unexpected result %T", value)
}
if raw == "" {
return store.PhoneCodeSnapshot{}, false, nil
}
var record store.PhoneCode
if err := json.Unmarshal([]byte(raw), &record); err != nil {
return store.PhoneCodeSnapshot{}, false, fmt.Errorf("redis get phone code snapshot decode: %w", err)
}
if record.Version != store.PhoneCodeVersionCurrent || record.Revision == "" {
return store.PhoneCodeSnapshot{}, false, fmt.Errorf("redis get phone code snapshot returned invalid version/revision")
}
return store.PhoneCodeSnapshot{Record: record, Revision: record.Revision}, true, nil
}
func (s *CodeStore) CompareAndUpdate(ctx context.Context, hash, expectedRevision string, next store.PhoneCode) (bool, error) {
if expectedRevision == "" || next.Version != store.PhoneCodeVersionCurrent || next.Purpose != "" {
return false, nil
}
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return false, err
}
next.Revision = revision
raw, err := json.Marshal(next)
if err != nil {
return false, fmt.Errorf("marshal compare-and-update phone code: %w", err)
}
value, err := s.c.Eval(
ctx,
compareAndUpdatePhoneCodeScript,
[]string{codeKey(hash)},
store.PhoneCodeVersionCurrent,
expectedRevision,
string(raw),
).Result()
if err != nil {
return false, fmt.Errorf("redis compare-and-update phone code: %w", err)
}
return redisCASApplied(value, "compare-and-update phone code")
}
func (s *CodeStore) CompareAndDelete(ctx context.Context, hash, expectedRevision string) (bool, error) {
if expectedRevision == "" {
return false, nil
}
value, err := s.c.Eval(
ctx,
compareAndDeletePhoneCodeScript,
[]string{codeKey(hash)},
store.PhoneCodeVersionCurrent,
expectedRevision,
).Result()
if err != nil {
return false, fmt.Errorf("redis compare-and-delete phone code: %w", err)
}
return redisCASApplied(value, "compare-and-delete phone code")
}
func redisCASApplied(value any, operation string) (bool, error) {
number, ok := value.(int64)
if !ok || (number != 0 && number != 1) {
return false, fmt.Errorf("redis %s: unexpected result %v (%T)", operation, value, value)
}
return number == 1, nil
}

View file

@ -0,0 +1,237 @@
package redisstore
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestRedisCodeStoreRevisionCAS(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016301",
Code: "111111",
Channel: "email_setup",
PendingEmail: "first@example.test",
MaxAttempts: 5,
}
key := hash("email-fixed")
if err := codes.Set(ctx, key, record, 45*time.Second); err != nil {
t.Fatal(err)
}
snapshot, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found || snapshot.Revision == "" || snapshot.Record.Revision != snapshot.Revision {
t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err)
}
before, err := client.PTTL(ctx, codeKey(key)).Result()
if err != nil {
t.Fatal(err)
}
next := snapshot.Record
next.Code = "222222"
next.Attempts = 1
if applied, err := codes.CompareAndUpdate(ctx, key, "stale-token", next); err != nil || applied {
t.Fatalf("wrong-token update applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndUpdate(ctx, key, snapshot.Revision, next); err != nil || !applied {
t.Fatalf("current update applied=%v err=%v", applied, err)
}
updated, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found || updated.Record.Code != next.Code || updated.Record.Attempts != 1 || updated.Revision == snapshot.Revision {
t.Fatalf("updated=%+v found=%v err=%v", updated, found, err)
}
after, err := client.PTTL(ctx, codeKey(key)).Result()
if err != nil || after <= 0 || after > before || before-after > 2*time.Second {
t.Fatalf("CAS TTL before=%v after=%v err=%v", before, after, err)
}
if applied, err := codes.CompareAndDelete(ctx, key, snapshot.Revision); err != nil || applied {
t.Fatalf("stale delete applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndDelete(ctx, key, updated.Revision); err != nil || !applied {
t.Fatalf("current delete applied=%v err=%v", applied, err)
}
assertRedisCodeMissing(t, ctx, codes, key)
}
func TestRedisCodeStoreRevisionCASFailClosedAndScopeIsolation(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
legacyHash := hash("legacy")
legacy := store.PhoneCode{
Version: 0,
Revision: "legacy-revision",
Phone: "15550016302",
Code: "12345",
}
raw, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := client.Set(ctx, codeKey(legacyHash), raw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, found, err := codes.GetSnapshot(ctx, legacyHash); err != nil || found {
t.Fatalf("legacy snapshot found=%v err=%v", found, err)
}
assertRedisCodeMissing(t, ctx, codes, legacyHash)
noRevisionHash := hash("no-revision")
legacy.Version = store.PhoneCodeVersionCurrent
legacy.Revision = ""
raw, err = json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := client.Set(ctx, codeKey(noRevisionHash), raw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, found, err := codes.GetSnapshot(ctx, noRevisionHash); err != nil || found {
t.Fatalf("revisionless snapshot found=%v err=%v", found, err)
}
assertRedisCodeMissing(t, ctx, codes, noRevisionHash)
scopedHash := hash("scoped")
scoped := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016303",
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 42,
AuthKeyID: [8]byte{1},
}
if err := codes.Set(ctx, scopedHash, scoped, time.Minute); err != nil {
t.Fatal(err)
}
snapshot, found, err := codes.GetSnapshot(ctx, scopedHash)
if err != nil || !found {
t.Fatalf("scoped snapshot found=%v err=%v", found, err)
}
if applied, err := codes.CompareAndUpdate(ctx, scopedHash, snapshot.Revision, snapshot.Record); err != nil || applied {
t.Fatalf("scoped update applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndDelete(ctx, scopedHash, snapshot.Revision); err != nil || applied {
t.Fatalf("scoped delete applied=%v err=%v", applied, err)
}
if _, found, _ := codes.Get(ctx, scopedHash); !found {
t.Fatal("generic CAS mutated scoped record")
}
}
func TestRedisCodeStoreRevisionCASPreventsABAAndHasSingleWinner(t *testing.T) {
codes, _, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016304",
Code: "123456",
Channel: "email_change",
}
key := hash("aba")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
old, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found {
t.Fatalf("old snapshot found=%v err=%v", found, err)
}
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
current, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found || current.Revision == old.Revision {
t.Fatalf("replacement current=%+v old=%+v found=%v err=%v", current, old, found, err)
}
if applied, err := codes.CompareAndDelete(ctx, key, old.Revision); err != nil || applied {
t.Fatalf("ABA stale delete applied=%v err=%v", applied, err)
}
const workers = 48
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
next := current.Record
next.Code = fmt.Sprintf("%06d", index)
applied, err := codes.CompareAndUpdate(ctx, key, current.Revision, next)
if err != nil {
errs <- err
return
}
results <- applied
}(i)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent CAS update: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("concurrent update winners=%d, want 1", winners)
}
winner, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found || winner.Revision == current.Revision {
t.Fatalf("winner=%+v found=%v err=%v", winner, found, err)
}
results = make(chan bool, workers)
errs = make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
applied, err := codes.CompareAndDelete(ctx, key, winner.Revision)
if err != nil {
errs <- err
return
}
results <- applied
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent CAS delete: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("concurrent delete winners=%d, want 1", winners)
}
}
func TestRedisCodeStoreLegacyUpdateCannotResurrectConsumedKey(t *testing.T) {
codes, _, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016305",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
}
key := hash("no-resurrection")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
stale, found, err := codes.Get(ctx, key)
if err != nil || !found {
t.Fatalf("load stale record found=%v err=%v", found, err)
}
if _, found, err := codes.TakeLoginCode(ctx, key, record.Phone); err != nil || !found {
t.Fatalf("consume before stale update found=%v err=%v", found, err)
}
stale.Attempts++
if err := codes.Update(ctx, key, stale); err != nil {
t.Fatalf("stale legacy Update: %v", err)
}
assertRedisCodeMissing(t, ctx, codes, key)
}

View file

@ -27,6 +27,7 @@ func TestRedisCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
oldHash := fmt.Sprintf("scope-old-%d", suffix)
newHash := fmt.Sprintf("scope-new-%d", suffix)
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: fmt.Sprintf("1555%d", suffix),
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
@ -81,3 +82,34 @@ func TestRedisCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
t.Fatalf("remaining redis keys=%d err=%v", exists, err)
}
}
func TestRedisCodeStoreConsumeScopedRejectsAndDeletesLegacyVersion(t *testing.T) {
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
}
ctx := context.Background()
c, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
hash := fmt.Sprintf("legacy-scope-%d", time.Now().UnixNano())
rec := store.PhoneCode{
Version: 0, Phone: "15550015004", Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone, UserID: 44, AuthKeyID: [8]byte{4},
}
scopeKey := codeScopeKey(rec.Scope())
t.Cleanup(func() { _ = c.Del(ctx, codeKey(hash), scopeKey).Err() })
codes := NewCodeStore(c)
if err := codes.Set(ctx, hash, rec, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.ConsumeScoped(ctx, hash, rec.Scope()); err != nil || found {
t.Fatalf("legacy scoped consume found=%v err=%v, want false/nil", found, err)
}
if exists, err := c.Exists(ctx, codeKey(hash), scopeKey).Result(); err != nil || exists != 0 {
t.Fatalf("legacy scoped keys remain=%d err=%v", exists, err)
}
}

View file

@ -0,0 +1,385 @@
package redisstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
const verifyLoginCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return {0, ''}
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1])
return {0, ''}
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return {0, ''}
end
if record.SignUpVerified == true then
return {0, ''}
end
local channel = record.Channel or ''
if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2]
or (channel ~= ARGV[6] and channel ~= ARGV[7])
or (record.Code or '') == '' or ARGV[3] == '' then
return {1, raw}
end
if (record.Code or '') ~= ARGV[3] then
local attempts = tonumber(record.Attempts or 0) + 1
record.Attempts = attempts
record.Revision = ARGV[8]
local max_attempts = tonumber(record.MaxAttempts or 0)
if not max_attempts or max_attempts <= 0 then
max_attempts = tonumber(ARGV[5]) or 0
end
if max_attempts <= 0 then
max_attempts = 1
end
local updated = cjson.encode(record)
if attempts >= max_attempts then
redis.call('DEL', KEYS[1])
else
redis.call('SET', KEYS[1], updated, 'KEEPTTL')
end
return {1, updated}
end
if ARGV[4] == '1' then
if tonumber(record.IssuedUserID or '0') ~= 0 then
return {1, raw}
end
record.SignUpVerified = true
record.Revision = ARGV[8]
local updated = cjson.encode(record)
redis.call('SET', KEYS[1], updated, 'KEEPTTL')
return {2, updated}
end
redis.call('DEL', KEYS[1])
return {2, raw}
`
const verifyScopedCodeScript = `
if redis.call('GET', KEYS[2]) ~= ARGV[1] then
return {0, ''}
end
local raw = redis.call('GET', KEYS[1])
if not raw then
redis.call('DEL', KEYS[2])
return {0, ''}
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1], KEYS[2])
return {0, ''}
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[2]) then
redis.call('DEL', KEYS[1], KEYS[2])
return {0, ''}
end
local encoded_auth_key = ''
if type(record.AuthKeyID) == 'table' then
encoded_auth_key = cjson.encode(record.AuthKeyID)
end
if (record.Purpose or '') ~= ARGV[6]
or tonumber(record.UserID or 0) ~= tonumber(ARGV[7])
or encoded_auth_key ~= ARGV[8]
or (record.Phone or '') ~= ARGV[9]
or record.SignUpVerified == true
or (record.Code or '') == '' then
redis.call('DEL', KEYS[1], KEYS[2])
return {0, ''}
end
if ARGV[3] == '' then
return {1, raw}
end
if (record.Code or '') ~= ARGV[3] then
local attempts = tonumber(record.Attempts or 0) + 1
record.Attempts = attempts
record.Revision = ARGV[5]
local max_attempts = tonumber(record.MaxAttempts or 0)
if not max_attempts or max_attempts <= 0 then
max_attempts = tonumber(ARGV[4]) or 0
end
if max_attempts <= 0 then
max_attempts = 1
end
local updated = cjson.encode(record)
if attempts >= max_attempts then
redis.call('DEL', KEYS[1], KEYS[2])
else
redis.call('SET', KEYS[1], updated, 'KEEPTTL')
end
return {1, updated}
end
redis.call('DEL', KEYS[1], KEYS[2])
return {2, raw}
`
const takeLoginCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return ''
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1])
return ''
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return ''
end
if record.SignUpVerified == true then
return ''
end
local channel = record.Channel or ''
if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2]
or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5]) then
return ''
end
redis.call('DEL', KEYS[1])
return raw
`
const consumeSignUpVerifiedScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return ''
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1])
return ''
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return ''
end
local channel = record.Channel or ''
if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2]
or (channel ~= ARGV[3] and channel ~= ARGV[4])
or tonumber(record.IssuedUserID or '0') ~= 0
or record.SignUpVerified ~= true then
return ''
end
redis.call('DEL', KEYS[1])
return raw
`
const invalidateLoginCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return ''
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1])
return ''
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return ''
end
local channel = record.Channel or ''
if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2]
or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5]) then
return ''
end
redis.call('DEL', KEYS[1])
return raw
`
func (s *CodeStore) VerifyLogin(ctx context.Context, hash, phone, code string, keepForSignUp bool, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
keep := 0
if keepForSignUp {
keep = 1
}
value, err := s.c.Eval(
ctx,
verifyLoginCodeScript,
[]string{codeKey(hash)},
store.PhoneCodeVersionCurrent,
phone,
code,
keep,
defaultMaxAttempts,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelEmailLogin,
revision,
).Result()
if err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code: %w", err)
}
return decodeRedisLoginCodeVerification(value)
}
func (s *CodeStore) VerifyScoped(ctx context.Context, hash string, scope store.PhoneCodeScope, code string, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
if !scope.Valid() {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
authKeyID, err := json.Marshal(scope.AuthKeyID)
if err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("marshal scoped phone code auth key: %w", err)
}
value, err := s.c.Eval(
ctx,
verifyScopedCodeScript,
[]string{codeKey(hash), codeScopeKey(scope)},
hash,
store.PhoneCodeVersionCurrent,
code,
defaultMaxAttempts,
revision,
scope.Purpose,
strconv.FormatInt(scope.UserID, 10),
string(authKeyID),
scope.Phone,
).Result()
if err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify scoped phone code: %w", err)
}
result, err := decodeRedisLoginCodeVerification(value)
if err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify scoped phone code: %w", err)
}
if result.Status != store.LoginCodeVerifyMissing && result.Record.Scope() != scope {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify scoped phone code returned a record outside the requested scope")
}
return result, nil
}
func (s *CodeStore) ConsumeSignUpVerified(ctx context.Context, hash, phone string) (store.PhoneCode, bool, error) {
return s.consumeLoginCode(
ctx,
consumeSignUpVerifiedScript,
"consume sign-up verified code",
hash,
phone,
true,
true,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelEmailLogin,
)
}
func (s *CodeStore) TakeLoginCode(ctx context.Context, hash, phone string) (store.PhoneCode, bool, error) {
return s.consumeLoginCode(
ctx,
takeLoginCodeScript,
"take login code",
hash,
phone,
false,
false,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelEmailLogin,
store.PhoneCodeChannelEmailSetupRequired,
)
}
func (s *CodeStore) InvalidateLoginCode(ctx context.Context, hash, phone string) (bool, error) {
_, found, err := s.consumeLoginCode(
ctx,
invalidateLoginCodeScript,
"invalidate login code",
hash,
phone,
false,
true,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelEmailLogin,
store.PhoneCodeChannelEmailSetupRequired,
)
return found, err
}
func (s *CodeStore) consumeLoginCode(ctx context.Context, script, operation, hash, phone string, requireVerified, allowVerified bool, channels ...string) (store.PhoneCode, bool, error) {
args := make([]any, 0, 2+len(channels))
args = append(args, store.PhoneCodeVersionCurrent, phone)
for _, channel := range channels {
args = append(args, channel)
}
value, err := s.c.Eval(
ctx,
script,
[]string{codeKey(hash)},
args...,
).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return store.PhoneCode{}, false, nil
}
return store.PhoneCode{}, false, fmt.Errorf("redis %s: %w", operation, err)
}
raw, ok := value.(string)
if !ok {
return store.PhoneCode{}, false, fmt.Errorf("redis %s: unexpected result %T", operation, value)
}
if raw == "" {
return store.PhoneCode{}, false, nil
}
var record store.PhoneCode
if err := json.Unmarshal([]byte(raw), &record); err != nil {
return store.PhoneCode{}, false, fmt.Errorf("redis %s decode: %w", operation, err)
}
if record.Version != store.PhoneCodeVersionCurrent || record.Purpose != "" || record.Phone != phone ||
!loginCodeChannelAllowed(record.Channel, channels) ||
(requireVerified && (record.IssuedUserID != 0 || !record.SignUpVerified)) ||
(!requireVerified && !allowVerified && record.SignUpVerified) {
return store.PhoneCode{}, false, fmt.Errorf("redis %s returned a record outside the requested login scope", operation)
}
return record, true, nil
}
func loginCodeChannelAllowed(channel string, allowed []string) bool {
for _, item := range allowed {
if channel == item {
return true
}
}
return false
}
func decodeRedisLoginCodeVerification(value any) (store.LoginCodeVerifyResult, error) {
items, ok := value.([]interface{})
if !ok || len(items) != 2 {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code: unexpected result %T", value)
}
statusNumber, ok := items[0].(int64)
if !ok || statusNumber < int64(store.LoginCodeVerifyMissing) || statusNumber > int64(store.LoginCodeVerifyAccepted) {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code: invalid status %v", items[0])
}
result := store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyStatus(statusNumber)}
if result.Status == store.LoginCodeVerifyMissing {
return result, nil
}
raw, ok := items[1].(string)
if !ok || raw == "" {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code: status %d has invalid record %T", result.Status, items[1])
}
if err := json.Unmarshal([]byte(raw), &result.Record); err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code decode: %w", err)
}
if result.Record.Version != store.PhoneCodeVersionCurrent {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code returned version %d", result.Record.Version)
}
return result, nil
}

View file

@ -0,0 +1,517 @@
package redisstore
import (
"context"
"fmt"
"math"
"os"
"sync"
"testing"
"time"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
func TestRedisCodeStoreAtomicLoginStateMachine(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
const phone = "15550016101"
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: 1000000101,
Phone: phone,
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
MaxAttempts: 2,
}
}
t.Run("version and corrupt records fail closed", func(t *testing.T) {
legacy := newRecord()
legacy.Version = 0
verifyHash := hash("legacy-verify")
if err := codes.Set(ctx, verifyHash, legacy, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, verifyHash, phone, legacy.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy verify = %+v err=%v", result, err)
}
assertRedisCodeMissing(t, ctx, codes, verifyHash)
// This is the actual pre-state-machine JSON shape: int64 fields were
// numbers, not the quoted strings emitted by current Set.
legacyRaw := fmt.Sprintf(
`{"Version":0,"IssuedUserID":1000000101,"Phone":%q,"Code":"12345","Channel":"phone","UserID":42,"SessionID":9007199254740993}`,
phone,
)
getLegacyHash := hash("legacy-raw-number-get")
if err := client.Set(ctx, codeKey(getLegacyHash), legacyRaw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if got, found, err := codes.Get(ctx, getLegacyHash); err != nil || found {
t.Fatalf("legacy raw-number Get=%+v found=%v err=%v, want Missing", got, found, err)
}
if exists, err := client.Exists(ctx, codeKey(getLegacyHash)).Result(); err != nil || exists != 0 {
t.Fatalf("legacy raw-number Get left key exists=%d err=%v", exists, err)
}
verifyLegacyHash := hash("legacy-raw-number-verify")
if err := client.Set(ctx, codeKey(verifyLegacyHash), legacyRaw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, verifyLegacyHash, phone, "12345", false, 5)
if err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy raw-number VerifyLogin=%+v err=%v, want Missing", result, err)
}
if exists, err := client.Exists(ctx, codeKey(verifyLegacyHash)).Result(); err != nil || exists != 0 {
t.Fatalf("legacy raw-number VerifyLogin left key exists=%d err=%v", exists, err)
}
unknown := newRecord()
unknown.Version++
takeHash := hash("unknown-take")
if err := codes.Set(ctx, takeHash, unknown, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.TakeLoginCode(ctx, takeHash, phone); err != nil || found {
t.Fatalf("unknown take found=%v err=%v", found, err)
}
assertRedisCodeMissing(t, ctx, codes, takeHash)
legacy.SignUpVerified = true
consumeHash := hash("legacy-consume")
if err := codes.Set(ctx, consumeHash, legacy, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, consumeHash, phone); err != nil || found {
t.Fatalf("legacy sign-up consume found=%v err=%v", found, err)
}
assertRedisCodeMissing(t, ctx, codes, consumeHash)
corruptHash := hash("corrupt")
if err := client.Set(ctx, codeKey(corruptHash), `{`, time.Minute).Err(); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, corruptHash, phone, "12345", false, 5)
if err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("corrupt verify = %+v err=%v", result, err)
}
assertRedisCodeMissing(t, ctx, codes, corruptHash)
})
t.Run("scope channel and issued-user gates", func(t *testing.T) {
record := newRecord()
scopeHash := hash("scope")
if err := codes.Set(ctx, scopeHash, record, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, scopeHash, "15550016999", record.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid || result.Record.Attempts != 0 {
t.Fatalf("cross-phone verify = %+v err=%v", result, err)
}
stored, found, err := codes.Get(ctx, scopeHash)
if err != nil || !found || stored.Attempts != 0 {
t.Fatalf("cross-phone stored=%+v found=%v err=%v", stored, found, err)
}
wrongChannel := record
wrongChannel.Channel = "email_setup"
channelHash := hash("channel")
if err := codes.Set(ctx, channelHash, wrongChannel, time.Minute); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, channelHash, phone, wrongChannel.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid {
t.Fatalf("wrong-channel verify = %+v err=%v", result, err)
}
if _, found, err := codes.TakeLoginCode(ctx, channelHash, phone); err != nil || found {
t.Fatalf("wrong-channel take found=%v err=%v", found, err)
}
issuedHash := hash("issued-existing")
record.IssuedUserID = math.MaxInt64 - 1
if err := codes.Set(ctx, issuedHash, record, time.Minute); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, issuedHash, phone, record.Code, true, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid || result.Record.IssuedUserID != record.IssuedUserID {
t.Fatalf("issued-existing keep = %+v err=%v, want exact int64 Invalid", result, err)
}
taken, found, err := codes.TakeLoginCode(ctx, issuedHash, phone)
if err != nil || !found || taken.IssuedUserID != record.IssuedUserID {
t.Fatalf("issued-existing take=%+v found=%v err=%v", taken, found, err)
}
setupRequired := newRecord()
setupRequired.Channel = store.PhoneCodeChannelEmailSetupRequired
setupRequired.Code = ""
setupHash := hash("setup-required")
if err := codes.Set(ctx, setupHash, setupRequired, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.TakeLoginCode(ctx, setupHash, phone); err != nil || !found {
t.Fatalf("setup-required take found=%v err=%v, want true", found, err)
}
})
t.Run("wrong attempts and ttl are atomic", func(t *testing.T) {
record := newRecord()
wrongHash := hash("wrong")
if err := codes.Set(ctx, wrongHash, record, 45*time.Second); err != nil {
t.Fatal(err)
}
before, err := client.PTTL(ctx, codeKey(wrongHash)).Result()
if err != nil {
t.Fatal(err)
}
first, err := codes.VerifyLogin(ctx, wrongHash, phone, "00000", false, 9)
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
t.Fatalf("first wrong = %+v err=%v", first, err)
}
after, err := client.PTTL(ctx, codeKey(wrongHash)).Result()
if err != nil || after <= 0 || after > before || before-after > 2*time.Second {
t.Fatalf("wrong-code TTL before=%v after=%v err=%v, want KEEPTTL", before, after, err)
}
second, err := codes.VerifyLogin(ctx, wrongHash, phone, "00000", false, 9)
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
t.Fatalf("threshold wrong = %+v err=%v", second, err)
}
assertRedisCodeMissing(t, ctx, codes, wrongHash)
})
t.Run("consume and sign-up marker terminal states", func(t *testing.T) {
record := newRecord()
consumeHash := hash("verify-consume")
if err := codes.Set(ctx, consumeHash, record, time.Minute); err != nil {
t.Fatal(err)
}
accepted, err := codes.VerifyLogin(ctx, consumeHash, phone, record.Code, false, 5)
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted {
t.Fatalf("consume verify = %+v err=%v", accepted, err)
}
assertRedisCodeMissing(t, ctx, codes, consumeHash)
signUp := record
signUp.IssuedUserID = 0
signUpHash := hash("signup")
if err := codes.Set(ctx, signUpHash, signUp, 45*time.Second); err != nil {
t.Fatal(err)
}
before, err := client.PTTL(ctx, codeKey(signUpHash)).Result()
if err != nil {
t.Fatal(err)
}
marked, err := codes.VerifyLogin(ctx, signUpHash, phone, signUp.Code, true, 5)
if err != nil || marked.Status != store.LoginCodeVerifyAccepted || !marked.Record.SignUpVerified {
t.Fatalf("mark sign-up = %+v err=%v", marked, err)
}
after, err := client.PTTL(ctx, codeKey(signUpHash)).Result()
if err != nil || after <= 0 || after > before || before-after > 2*time.Second {
t.Fatalf("marker TTL before=%v after=%v err=%v, want KEEPTTL", before, after, err)
}
repeated, err := codes.VerifyLogin(ctx, signUpHash, phone, signUp.Code, true, 5)
if err != nil || repeated.Status != store.LoginCodeVerifyMissing {
t.Fatalf("repeated marker verify = %+v err=%v", repeated, err)
}
if _, found, err := codes.TakeLoginCode(ctx, signUpHash, phone); err != nil || found {
t.Fatalf("terminal marker take found=%v err=%v, want false", found, err)
}
consumed, found, err := codes.ConsumeSignUpVerified(ctx, signUpHash, phone)
if err != nil || !found || !consumed.SignUpVerified || consumed.IssuedUserID != 0 {
t.Fatalf("consume marker=%+v found=%v err=%v", consumed, found, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, signUpHash, phone); err != nil || found {
t.Fatalf("second marker consume found=%v err=%v", found, err)
}
})
}
func TestRedisCodeStoreAtomicLoginConcurrency(t *testing.T) {
codes, _, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
const (
phone = "15550016102"
workers = 48
)
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
MaxAttempts: 7,
}
}
t.Run("consume verify one accepted", func(t *testing.T) {
key := hash("verify-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisVerify(t, codes, key, phone, "12345", false, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
t.Fatalf("verify race statuses=%+v", statuses)
}
})
t.Run("mark and consume each one winner", func(t *testing.T) {
key := hash("signup-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisVerify(t, codes, key, phone, "12345", true, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 {
t.Fatalf("sign-up verify race statuses=%+v", statuses)
}
if found := concurrentRedisConsume(t, codes, key, phone, workers); found != 1 {
t.Fatalf("sign-up consume winners=%d, want 1", found)
}
})
t.Run("take one winner", func(t *testing.T) {
key := hash("take-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
if found := concurrentRedisTake(t, codes, key, phone, workers); found != 1 {
t.Fatalf("take winners=%d, want 1", found)
}
})
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
key := hash("wrong-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisVerify(t, codes, key, phone, "00000", false, workers)
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
t.Fatalf("wrong race statuses=%+v", statuses)
}
assertRedisCodeMissing(t, ctx, codes, key)
})
t.Run("verify and take share one winner", func(t *testing.T) {
key := hash("mixed-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(take bool) {
defer wg.Done()
if take {
_, found, err := codes.TakeLoginCode(ctx, key, phone)
if err != nil {
errs <- err
return
}
results <- found
return
}
result, err := codes.VerifyLogin(ctx, key, phone, "12345", false, 5)
if err != nil {
errs <- err
return
}
results <- result.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("mixed race: %v", err)
}
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("mixed race winners=%d, want 1", winners)
}
})
t.Run("sign-up mark and take share one winner", func(t *testing.T) {
key := hash("mixed-signup-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(take bool) {
defer wg.Done()
if take {
_, found, err := codes.TakeLoginCode(ctx, key, phone)
if err != nil {
errs <- err
return
}
results <- found
return
}
result, err := codes.VerifyLogin(ctx, key, phone, "12345", true, 5)
if err != nil {
errs <- err
return
}
results <- result.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("mixed sign-up race: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("mixed sign-up race winners=%d, want 1", winners)
}
})
}
func newRedisLoginCodeHarness(t *testing.T) (*CodeStore, *redis.Client, func(string) string) {
t.Helper()
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis login-code integration tests")
}
ctx := context.Background()
client, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open redis: %v", err)
}
prefix := fmt.Sprintf("atomic-login-%d-", time.Now().UnixNano())
var mu sync.Mutex
keys := make([]string, 0)
newHash := func(label string) string {
value := prefix + label
mu.Lock()
keys = append(keys, codeKey(value))
mu.Unlock()
return value
}
t.Cleanup(func() {
mu.Lock()
cleanup := append([]string(nil), keys...)
mu.Unlock()
if len(cleanup) > 0 {
_ = client.Del(context.Background(), cleanup...).Err()
}
_ = client.Close()
})
return NewCodeStore(client), client, newHash
}
func assertRedisCodeMissing(t *testing.T, ctx context.Context, codes *CodeStore, hash string) {
t.Helper()
if _, found, err := codes.Get(ctx, hash); err != nil || found {
t.Fatalf("code %q found=%v err=%v, want missing", hash, found, err)
}
}
func concurrentRedisVerify(t *testing.T, codes *CodeStore, hash, phone, code string, keep bool, workers int) map[store.LoginCodeVerifyStatus]int {
t.Helper()
ctx := context.Background()
results := make(chan store.LoginCodeVerifyStatus, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := codes.VerifyLogin(ctx, hash, phone, code, keep, 5)
if err != nil {
errs <- err
return
}
results <- result.Status
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("VerifyLogin: %v", err)
}
counts := make(map[store.LoginCodeVerifyStatus]int)
for status := range results {
counts[status]++
}
return counts
}
func concurrentRedisTake(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
t.Helper()
ctx := context.Background()
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.TakeLoginCode(ctx, hash, phone)
if err != nil {
errs <- err
return
}
results <- found
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("TakeLoginCode: %v", err)
}
return countRedisTrue(results)
}
func concurrentRedisConsume(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
t.Helper()
ctx := context.Background()
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.ConsumeSignUpVerified(ctx, hash, phone)
if err != nil {
errs <- err
return
}
results <- found
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("ConsumeSignUpVerified: %v", err)
}
return countRedisTrue(results)
}
func countRedisTrue(results <-chan bool) int {
count := 0
for found := range results {
if found {
count++
}
}
return count
}

View file

@ -0,0 +1,103 @@
package redisstore
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestRedisCodeStoreAtomicLoginInvalidation(t *testing.T) {
codes, _, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
const phone = "15550016111"
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
MaxAttempts: 5,
}
}
t.Run("owner cleanup may delete a terminal sign-up marker", func(t *testing.T) {
key := hash("invalidate-marker")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
verified, err := codes.VerifyLogin(ctx, key, phone, "12345", true, 5)
if err != nil || verified.Status != store.LoginCodeVerifyAccepted || !verified.Record.SignUpVerified {
t.Fatalf("mark sign-up=%+v err=%v", verified, err)
}
if removed, err := codes.InvalidateLoginCode(ctx, key, "15550016999"); err != nil || removed {
t.Fatalf("cross-phone invalidate removed=%v err=%v", removed, err)
}
if removed, err := codes.InvalidateLoginCode(ctx, key, phone); err != nil || !removed {
t.Fatalf("owner invalidate removed=%v err=%v", removed, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, key, phone); err != nil || found {
t.Fatalf("consume after invalidate found=%v err=%v", found, err)
}
})
t.Run("legacy records fail closed", func(t *testing.T) {
key := hash("invalidate-legacy")
legacy := newRecord()
legacy.Version = 0
if err := codes.Set(ctx, key, legacy, time.Minute); err != nil {
t.Fatal(err)
}
if removed, err := codes.InvalidateLoginCode(ctx, key, phone); err != nil || removed {
t.Fatalf("legacy invalidate removed=%v err=%v, want false", removed, err)
}
assertRedisCodeMissing(t, ctx, codes, key)
})
t.Run("invalidate and sign-up consume have one winner", func(t *testing.T) {
key := hash("invalidate-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
if verified, err := codes.VerifyLogin(ctx, key, phone, "12345", true, 5); err != nil || verified.Status != store.LoginCodeVerifyAccepted {
t.Fatalf("mark sign-up=%+v err=%v", verified, err)
}
const workers = 48
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(invalidate bool) {
defer wg.Done()
if invalidate {
removed, err := codes.InvalidateLoginCode(ctx, key, phone)
if err != nil {
errs <- err
return
}
results <- removed
return
}
_, found, err := codes.ConsumeSignUpVerified(ctx, key, phone)
if err != nil {
errs <- err
return
}
results <- found
}(i%2 == 0)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("invalidate/consume race: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("invalidate/consume winners=%d, want 1", winners)
}
})
}

View file

@ -3,7 +3,6 @@ package redisstore
import (
"context"
"fmt"
"math"
"time"
"github.com/redis/go-redis/v9"
@ -23,6 +22,16 @@ func rateLimitKey(key string) string {
return "ratelimit:" + key
}
const rateLimitIncrementScript = `
local count = redis.call('INCRBY', KEYS[1], ARGV[1])
local ttl_ms = redis.call('PTTL', KEYS[1])
if ttl_ms < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
ttl_ms = tonumber(ARGV[2])
end
return {count, ttl_ms}
`
func (l *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) {
return l.AllowN(ctx, key, 1, limit, window)
}
@ -41,24 +50,29 @@ func (l *RateLimiter) AllowN(ctx context.Context, key string, cost, limit int, w
return false, 0, fmt.Errorf("redis rate limiter: nil client")
}
redisKey := rateLimitKey(key)
count, err := l.c.IncrBy(ctx, redisKey, int64(cost)).Result()
if err != nil {
return false, 0, fmt.Errorf("redis incrby rate limit: %w", err)
windowMillis := window.Milliseconds()
if windowMillis <= 0 {
windowMillis = 1
}
if count == int64(cost) {
if err := l.c.Expire(ctx, redisKey, window).Err(); err != nil {
return false, 0, fmt.Errorf("redis expire rate limit: %w", err)
}
value, err := l.c.Eval(ctx, rateLimitIncrementScript, []string{redisKey}, cost, windowMillis).Result()
if err != nil {
return false, 0, fmt.Errorf("redis increment rate limit: %w", err)
}
items, ok := value.([]interface{})
if !ok || len(items) != 2 {
return false, 0, fmt.Errorf("redis increment rate limit: unexpected result %T", value)
}
count, countOK := items[0].(int64)
ttlMillis, ttlOK := items[1].(int64)
if !countOK || !ttlOK || ttlMillis <= 0 {
return false, 0, fmt.Errorf("redis increment rate limit: invalid result %#v", items)
}
if count <= int64(limit) {
return true, 0, nil
}
ttl, err := l.c.TTL(ctx, redisKey).Result()
if err != nil {
return false, 0, fmt.Errorf("redis ttl rate limit: %w", err)
retry := (ttlMillis + 999) / 1000
if retry <= 0 {
retry = 1
}
if ttl <= 0 {
ttl = window
}
return false, int(math.Ceil(ttl.Seconds())), nil
return false, int(retry), nil
}

View file

@ -0,0 +1,288 @@
package redisstore
import (
"context"
"encoding/json"
"math"
"sync"
"testing"
"time"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
func TestRedisCodeStoreAtomicScopedVerification(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016121",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: math.MaxInt64 - 121,
AuthKeyID: [8]byte{1, 2, 3, 4},
SessionID: math.MaxInt64 - 21,
MaxAttempts: 2,
}
}
recordForCleanup := newRecord()
t.Cleanup(func() { _ = client.Del(context.Background(), codeScopeKey(recordForCleanup.Scope())).Err() })
t.Run("only the active hash and exact scope can mutate", func(t *testing.T) {
record := newRecord()
oldHash := hash("scoped-old")
currentHash := hash("scoped-current")
if err := codes.Set(ctx, oldHash, record, time.Minute); err != nil {
t.Fatal(err)
}
if err := codes.Set(ctx, currentHash, record, time.Minute); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, oldHash, record.Scope(), record.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("old-hash verify=%+v err=%v", result, err)
}
otherScope := record.Scope()
otherScope.AuthKeyID = [8]byte{9}
if result, err := codes.VerifyScoped(ctx, currentHash, otherScope, "00000", 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("cross-scope verify=%+v err=%v", result, err)
}
stored, found, err := codes.Get(ctx, currentHash)
if err != nil || !found || stored.Attempts != 0 || stored.UserID != record.UserID {
t.Fatalf("victim after cross-scope verify=%+v found=%v err=%v", stored, found, err)
}
})
t.Run("wrong attempts preserve ttl then delete code and index", func(t *testing.T) {
record := newRecord()
key := hash("scoped-wrong")
if err := codes.Set(ctx, key, record, 45*time.Second); err != nil {
t.Fatal(err)
}
beforeTTL, err := client.PTTL(ctx, codeKey(key)).Result()
if err != nil {
t.Fatal(err)
}
before, found, err := codes.Get(ctx, key)
if err != nil || !found {
t.Fatalf("get before found=%v err=%v", found, err)
}
first, err := codes.VerifyScoped(ctx, key, record.Scope(), "00000", 9)
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 || first.Record.UserID != record.UserID || first.Record.SessionID != record.SessionID {
t.Fatalf("first wrong=%+v err=%v", first, err)
}
afterTTL, err := client.PTTL(ctx, codeKey(key)).Result()
if err != nil || afterTTL <= 0 || afterTTL > beforeTTL || beforeTTL-afterTTL > 2*time.Second {
t.Fatalf("wrong-attempt TTL before=%v after=%v err=%v", beforeTTL, afterTTL, err)
}
after, found, err := codes.Get(ctx, key)
if err != nil || !found || after.Revision == before.Revision || after.UserID != record.UserID || after.SessionID != record.SessionID {
t.Fatalf("get after=%+v found=%v err=%v", after, found, err)
}
second, err := codes.VerifyScoped(ctx, key, record.Scope(), "00000", 9)
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
t.Fatalf("threshold wrong=%+v err=%v", second, err)
}
assertRedisScopedMissing(t, ctx, client, key, record.Scope())
if result, err := codes.VerifyScoped(ctx, key, record.Scope(), record.Code, 9); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("verify after exhaustion=%+v err=%v", result, err)
}
})
t.Run("correct code consumes both keys exactly once", func(t *testing.T) {
record := newRecord()
key := hash("scoped-correct")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
expected, found, err := codes.Get(ctx, key)
if err != nil || !found {
t.Fatalf("get expected found=%v err=%v", found, err)
}
accepted, err := codes.VerifyScoped(ctx, key, record.Scope(), record.Code, 5)
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record != expected || accepted.Record.UserID != record.UserID {
t.Fatalf("accepted=%+v err=%v, want %+v", accepted, err, expected)
}
assertRedisScopedMissing(t, ctx, client, key, record.Scope())
if repeated, err := codes.VerifyScoped(ctx, key, record.Scope(), record.Code, 5); err != nil || repeated.Status != store.LoginCodeVerifyMissing {
t.Fatalf("repeated verify=%+v err=%v", repeated, err)
}
})
t.Run("legacy corrupt and inconsistent records fail closed", func(t *testing.T) {
legacy := newRecord()
legacy.Version = 0
legacyHash := hash("scoped-legacy")
if err := codes.Set(ctx, legacyHash, legacy, time.Minute); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, legacyHash, legacy.Scope(), legacy.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy verify=%+v err=%v", result, err)
}
assertRedisScopedMissing(t, ctx, client, legacyHash, legacy.Scope())
corrupt := newRecord()
corruptHash := hash("scoped-corrupt")
if err := codes.Set(ctx, corruptHash, corrupt, time.Minute); err != nil {
t.Fatal(err)
}
if err := client.Set(ctx, codeKey(corruptHash), `{`, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, corruptHash, corrupt.Scope(), corrupt.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("corrupt verify=%+v err=%v", result, err)
}
assertRedisScopedMissing(t, ctx, client, corruptHash, corrupt.Scope())
inconsistent := newRecord()
inconsistentHash := hash("scoped-inconsistent")
if err := codes.Set(ctx, inconsistentHash, inconsistent, time.Minute); err != nil {
t.Fatal(err)
}
stored, found, err := codes.Get(ctx, inconsistentHash)
if err != nil || !found {
t.Fatalf("get inconsistent seed found=%v err=%v", found, err)
}
stored.Phone = "15550016999"
raw, err := json.Marshal(stored)
if err != nil {
t.Fatal(err)
}
if err := client.Set(ctx, codeKey(inconsistentHash), raw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, inconsistentHash, inconsistent.Scope(), inconsistent.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("inconsistent verify=%+v err=%v", result, err)
}
assertRedisScopedMissing(t, ctx, client, inconsistentHash, inconsistent.Scope())
})
}
func TestRedisCodeStoreAtomicScopedConcurrency(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
const workers = 48
newRecord := func(maxAttempts int) store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016122",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: math.MaxInt64 - 122,
AuthKeyID: [8]byte{5, 6, 7, 8},
SessionID: math.MaxInt64 - 22,
MaxAttempts: maxAttempts,
}
}
cleanupRecord := newRecord(7)
t.Cleanup(func() { _ = client.Del(context.Background(), codeScopeKey(cleanupRecord.Scope())).Err() })
t.Run("correct verification has one winner", func(t *testing.T) {
record := newRecord(7)
key := hash("scoped-verify-race")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisScopedVerify(t, codes, key, record.Scope(), record.Code, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
t.Fatalf("correct race statuses=%+v", statuses)
}
})
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
record := newRecord(7)
key := hash("scoped-wrong-race")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisScopedVerify(t, codes, key, record.Scope(), "00000", workers)
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
t.Fatalf("wrong race statuses=%+v", statuses)
}
assertRedisScopedMissing(t, ctx, client, key, record.Scope())
})
t.Run("verification and cancellation share one winner", func(t *testing.T) {
record := newRecord(7)
key := hash("scoped-mixed-race")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(cancel bool) {
defer wg.Done()
if cancel {
_, found, err := codes.ConsumeScoped(ctx, key, record.Scope())
if err != nil {
errs <- err
return
}
results <- found
return
}
result, err := codes.VerifyScoped(ctx, key, record.Scope(), record.Code, 5)
if err != nil {
errs <- err
return
}
results <- result.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("verify/cancel race: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("verify/cancel winners=%d, want 1", winners)
}
})
}
func concurrentRedisScopedVerify(t *testing.T, codes *CodeStore, hash string, scope store.PhoneCodeScope, code string, workers int) map[store.LoginCodeVerifyStatus]int {
t.Helper()
results := make(chan store.LoginCodeVerifyStatus, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := codes.VerifyScoped(context.Background(), hash, scope, code, 5)
if err != nil {
errs <- err
return
}
results <- result.Status
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("VerifyScoped: %v", err)
}
statuses := make(map[store.LoginCodeVerifyStatus]int)
for status := range results {
statuses[status]++
}
return statuses
}
func assertRedisScopedMissing(t *testing.T, ctx context.Context, client *redis.Client, hash string, scope store.PhoneCodeScope) {
t.Helper()
exists, err := client.Exists(ctx, codeKey(hash), codeScopeKey(scope)).Result()
if err != nil || exists != 0 {
t.Fatalf("scoped keys remain=%d err=%v", exists, err)
}
}