feat: sync configurable OTP delivery providers

This commit is contained in:
A 2026-07-17 17:04:39 +08:00
parent c18f773701
commit 6af61f26ba
28 changed files with 2100 additions and 118 deletions

View file

@ -11,6 +11,7 @@ import (
const (
PhoneCodePurposeChangePhone = "change_phone"
PhoneCodeChannelPhone = "phone"
PhoneCodeChannelSMS = "sms"
PhoneCodeChannelEmailLogin = "email_login"
PhoneCodeChannelEmailSetupRequired = "email_setup_required"
)
@ -34,8 +35,12 @@ type PhoneCode struct {
SignUpVerified bool
Phone string
Code string
Channel string
Purpose string
// DeliveryID is the stable, opaque idempotency key used for the outbound
// provider call that carries this code. It contains no recipient or secret
// material and is rotated whenever a genuinely new code is issued.
DeliveryID string
Channel string
Purpose string
// UserID is also encoded as a string because scoped verification mutates the
// record in Redis Lua and must not round an int64 owner through cjson.
UserID int64 `json:",string"`

View file

@ -191,7 +191,7 @@ func (s *CodeStore) InvalidateLoginCode(_ context.Context, hash, phone string) (
}
func loginCodeVerifiable(record store.PhoneCode) bool {
return record.Channel == store.PhoneCodeChannelPhone || record.Channel == store.PhoneCodeChannelEmailLogin
return record.Channel == store.PhoneCodeChannelPhone || record.Channel == store.PhoneCodeChannelSMS || record.Channel == store.PhoneCodeChannelEmailLogin
}
func loginCodeTakeable(record store.PhoneCode) bool {

View file

@ -31,14 +31,14 @@ if record.SignUpVerified == true then
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 (channel ~= ARGV[6] and channel ~= ARGV[7] and channel ~= ARGV[8])
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]
record.Revision = ARGV[9]
local max_attempts = tonumber(record.MaxAttempts or 0)
if not max_attempts or max_attempts <= 0 then
max_attempts = tonumber(ARGV[5]) or 0
@ -59,7 +59,7 @@ if ARGV[4] == '1' then
return {1, raw}
end
record.SignUpVerified = true
record.Revision = ARGV[8]
record.Revision = ARGV[9]
local updated = cjson.encode(record)
redis.call('SET', KEYS[1], updated, 'KEEPTTL')
return {2, updated}
@ -144,7 +144,7 @@ if record.SignUpVerified == true then
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
or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5] and channel ~= ARGV[6]) then
return ''
end
redis.call('DEL', KEYS[1])
@ -167,7 +167,7 @@ if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
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 (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5])
or tonumber(record.IssuedUserID or '0') ~= 0
or record.SignUpVerified ~= true then
return ''
@ -192,7 +192,7 @@ if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
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
or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5] and channel ~= ARGV[6]) then
return ''
end
redis.call('DEL', KEYS[1])
@ -218,6 +218,7 @@ func (s *CodeStore) VerifyLogin(ctx context.Context, hash, phone, code string, k
keep,
defaultMaxAttempts,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelSMS,
store.PhoneCodeChannelEmailLogin,
revision,
).Result()
@ -276,6 +277,7 @@ func (s *CodeStore) ConsumeSignUpVerified(ctx context.Context, hash, phone strin
true,
true,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelSMS,
store.PhoneCodeChannelEmailLogin,
)
}
@ -290,6 +292,7 @@ func (s *CodeStore) TakeLoginCode(ctx context.Context, hash, phone string) (stor
false,
false,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelSMS,
store.PhoneCodeChannelEmailLogin,
store.PhoneCodeChannelEmailSetupRequired,
)
@ -305,6 +308,7 @@ func (s *CodeStore) InvalidateLoginCode(ctx context.Context, hash, phone string)
false,
true,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelSMS,
store.PhoneCodeChannelEmailLogin,
store.PhoneCodeChannelEmailSetupRequired,
)

View file

@ -29,6 +29,39 @@ func TestRedisCodeStoreAtomicLoginStateMachine(t *testing.T) {
}
}
t.Run("sms channel participates in every login transition", func(t *testing.T) {
sms := newRecord()
sms.Channel = store.PhoneCodeChannelSMS
sms.IssuedUserID = 0
verifyHash := hash("sms-verify")
if err := codes.Set(ctx, verifyHash, sms, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, verifyHash, phone, sms.Code, true, 5)
if err != nil || result.Status != store.LoginCodeVerifyAccepted || !result.Record.SignUpVerified {
t.Fatalf("sms verify = %+v err=%v", result, err)
}
if consumed, found, err := codes.ConsumeSignUpVerified(ctx, verifyHash, phone); err != nil || !found || consumed.Channel != store.PhoneCodeChannelSMS {
t.Fatalf("sms signup consume=%+v found=%v err=%v", consumed, found, err)
}
takeHash := hash("sms-take")
if err := codes.Set(ctx, takeHash, sms, time.Minute); err != nil {
t.Fatal(err)
}
if taken, found, err := codes.TakeLoginCode(ctx, takeHash, phone); err != nil || !found || taken.Channel != store.PhoneCodeChannelSMS {
t.Fatalf("sms take=%+v found=%v err=%v", taken, found, err)
}
invalidateHash := hash("sms-invalidate")
if err := codes.Set(ctx, invalidateHash, sms, time.Minute); err != nil {
t.Fatal(err)
}
if found, err := codes.InvalidateLoginCode(ctx, invalidateHash, phone); err != nil || !found {
t.Fatalf("sms invalidate found=%v err=%v", found, err)
}
})
t.Run("version and corrupt records fail closed", func(t *testing.T) {
legacy := newRecord()
legacy.Version = 0