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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue