feat: sync configurable OTP delivery providers
This commit is contained in:
parent
c18f773701
commit
6af61f26ba
28 changed files with 2100 additions and 118 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue