perf: sync protocol and core hardening updates
This commit is contained in:
parent
152fed3b87
commit
4390ebf5a9
283 changed files with 29231 additions and 2295 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
147
internal/store/redisstore/code_cas.go
Normal file
147
internal/store/redisstore/code_cas.go
Normal 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
|
||||
}
|
||||
237
internal/store/redisstore/code_cas_integration_test.go
Normal file
237
internal/store/redisstore/code_cas_integration_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
385
internal/store/redisstore/login_code.go
Normal file
385
internal/store/redisstore/login_code.go
Normal 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
|
||||
}
|
||||
517
internal/store/redisstore/login_code_integration_test.go
Normal file
517
internal/store/redisstore/login_code_integration_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
288
internal/store/redisstore/scoped_code_state_integration_test.go
Normal file
288
internal/store/redisstore/scoped_code_state_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue