From a376ac07227c6dab99f191715622a1152d855322 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:03:39 +0800 Subject: [PATCH] feat: add Redis support for distributed passkey sessions - Implement SessionStore interface for pluggable storage backends - Add redisStore for distributed environment - Add memoryStore as fallback for single instance - Update router to initialize Redis client when configured - Update .env.example with Redis documentation --- .env.example | 2 +- backend/go.mod | 3 + backend/go.sum | 6 + backend/internal/passkey/passkey.go | 182 +++++++++++++++++++++------- backend/router/setRouter.go | 10 ++ 5 files changed, 157 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index 6da81f3..842e020 100644 --- a/.env.example +++ b/.env.example @@ -24,7 +24,7 @@ DB_DSN= DB_MAX_OPEN_CONNS=10 DB_MAX_IDLE_CONNS=5 -# --- Redis 配置(可选)--- +# --- Redis 配置(可选,用于分布式 passkey session)--- # REDIS_HOST=localhost # REDIS_PORT=6379 # REDIS_PASSWORD= diff --git a/backend/go.mod b/backend/go.mod index 339aea9..17fe31d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -27,6 +27,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect github.com/fxamacker/cbor/v2 v2.8.0 // indirect @@ -59,10 +60,12 @@ require ( github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/redis/go-redis/v9 v9.22.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.16.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7834141..50c170a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -7,6 +7,8 @@ github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1 github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= @@ -112,6 +114,8 @@ github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQ github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -141,6 +145,8 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/backend/internal/passkey/passkey.go b/backend/internal/passkey/passkey.go index 1742d5a..1694146 100644 --- a/backend/internal/passkey/passkey.go +++ b/backend/internal/passkey/passkey.go @@ -3,8 +3,10 @@ package passkey import ( "bytes" + "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strconv" @@ -13,23 +15,112 @@ import ( "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" + "github.com/redis/go-redis/v9" "opencatd-open/internal/store" "gorm.io/gorm" ) +const ( + sessionPrefix = "passkey:session:" + sessionTTL = 5 * time.Minute +) + type Config struct { RPID string Origins []string Name string + Redis *redis.Client // 可选,nil 时使用内存存储 } -// Service WebAuthn 服务:凭据存储 + challenge 会话(内存)。 -type Service struct { - wa *webauthn.WebAuthn - db *gorm.DB +// SessionStore challenge 会话存储接口 +type SessionStore interface { + Set(ctx context.Context, session *webauthn.SessionData) error + Get(ctx context.Context, challenge string) (*webauthn.SessionData, bool, error) + Delete(ctx context.Context, challenge string) error +} +// memoryStore 内存存储(单实例) +type memoryStore struct { mu sync.Mutex - sessions map[string]webauthn.SessionData // keyed by challenge + sessions map[string]webauthn.SessionData +} + +func newMemoryStore() *memoryStore { + return &memoryStore{sessions: make(map[string]webauthn.SessionData)} +} + +func (m *memoryStore) Set(_ context.Context, session *webauthn.SessionData) error { + m.mu.Lock() + m.sessions[session.Challenge] = *session + m.mu.Unlock() + return nil +} + +func (m *memoryStore) Get(_ context.Context, challenge string) (*webauthn.SessionData, bool, error) { + m.mu.Lock() + sess, ok := m.sessions[challenge] + m.mu.Unlock() + if !ok { + return nil, false, nil + } + // 检查过期 + if !sess.Expires.IsZero() && time.Now().After(sess.Expires) { + return nil, false, nil + } + return &sess, true, nil +} + +func (m *memoryStore) Delete(_ context.Context, challenge string) error { + m.mu.Lock() + delete(m.sessions, challenge) + m.mu.Unlock() + return nil +} + +// redisStore Redis 存储(分布式) +type redisStore struct { + rdb *redis.Client +} + +func newRedisStore(rdb *redis.Client) *redisStore { + return &redisStore{rdb: rdb} +} + +func (r *redisStore) Set(ctx context.Context, session *webauthn.SessionData) error { + data, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + key := sessionPrefix + session.Challenge + return r.rdb.Set(ctx, key, data, sessionTTL).Err() +} + +func (r *redisStore) Get(ctx context.Context, challenge string) (*webauthn.SessionData, bool, error) { + key := sessionPrefix + challenge + data, err := r.rdb.Get(ctx, key).Bytes() + if err == redis.Nil { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("redis get: %w", err) + } + var sess webauthn.SessionData + if err := json.Unmarshal(data, &sess); err != nil { + return nil, false, fmt.Errorf("unmarshal session: %w", err) + } + return &sess, true, nil +} + +func (r *redisStore) Delete(ctx context.Context, challenge string) error { + key := sessionPrefix + challenge + return r.rdb.Del(ctx, key).Err() +} + +// Service WebAuthn 服务:凭据存储 + challenge 会话。 +type Service struct { + wa *webauthn.WebAuthn + db *gorm.DB + sessions SessionStore } func New(db *gorm.DB, cfg Config) (*Service, error) { @@ -41,7 +132,16 @@ func New(db *gorm.DB, cfg Config) (*Service, error) { if err != nil { return nil, err } - return &Service{wa: wa, db: db, sessions: map[string]webauthn.SessionData{}}, nil + + // 根据配置选择存储后端 + var store SessionStore + if cfg.Redis != nil { + store = newRedisStore(cfg.Redis) + } else { + store = newMemoryStore() + } + + return &Service{wa: wa, db: db, sessions: store}, nil } // webUser 实现 go-webauthn 的 User 接口。 @@ -102,22 +202,30 @@ func (s *Service) BeginRegistration(u *store.User) (*protocol.CredentialCreation if err != nil { return nil, err } - s.storeSession(session) + if err := s.sessions.Set(context.Background(), session); err != nil { + return nil, err + } return creation, nil } // FinishRegistration 校验浏览器返回的凭据并落库。 func (s *Service) FinishRegistration(u *store.User, challenge string, body []byte, name string) error { - session, ok := s.takeSession(challenge) + session, ok, err := s.sessions.Get(context.Background(), challenge) + if err != nil { + return err + } if !ok { return errors.New("challenge 已过期或不存在") } + // 删除已使用的 challenge + _ = s.sessions.Delete(context.Background(), challenge) + wu, err := s.loadWebUser(u) if err != nil { return err } req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) - cred, err := s.wa.FinishRegistration(wu, session, req) + cred, err := s.wa.FinishRegistration(wu, *session, req) if err != nil { return err } @@ -144,7 +252,9 @@ func (s *Service) BeginLogin(u *store.User) (*protocol.CredentialAssertion, erro if err != nil { return nil, err } - s.storeSession(session) + if err := s.sessions.Set(context.Background(), session); err != nil { + return nil, err + } return assertion, nil } @@ -154,22 +264,29 @@ func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error if err != nil { return nil, err } - s.storeSession(session) + if err := s.sessions.Set(context.Background(), session); err != nil { + return nil, err + } return assertion, nil } // FinishLogin 校验断言并更新签名计数。 func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error { - session, ok := s.takeSession(challenge) + session, ok, err := s.sessions.Get(context.Background(), challenge) + if err != nil { + return err + } if !ok { return errors.New("challenge 已过期或不存在") } + _ = s.sessions.Delete(context.Background(), challenge) + wu, err := s.loadWebUser(u) if err != nil { return err } req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) - cred, err := s.wa.FinishLogin(wu, session, req) + cred, err := s.wa.FinishLogin(wu, *session, req) if err != nil { return err } @@ -178,10 +295,15 @@ func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) erro // FinishDiscoverableLogin 通过凭据定位用户并校验断言。 func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store.User, error) { - session, ok := s.takeSession(challenge) + session, ok, err := s.sessions.Get(context.Background(), challenge) + if err != nil { + return nil, err + } if !ok { return nil, errors.New("challenge 已过期或不存在") } + _ = s.sessions.Delete(context.Background(), challenge) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) // 先通过 credential_id 反查用户 @@ -205,13 +327,8 @@ func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store if err != nil { continue } - cred, err := s.wa.FinishLogin(wu, session, req) + cred, err := s.wa.FinishLogin(wu, *session, req) if err != nil { - // 不是这个用户的 passkey,继续尝试 - session, _ = s.takeSession(challenge) - if !ok { - return nil, errors.New("challenge 已过期或不存在") - } continue } _ = s.updateCredential(u.ID, cred) @@ -248,28 +365,3 @@ func (s *Service) updateCredential(userID uint64, cred *webauthn.Credential) err Where("user_id = ? AND credential_id = ?", userID, cred.ID). Update("credential", raw).Error } - -// --------------------------------------------------------------------------- -// challenge 会话 - -func (s *Service) storeSession(session *webauthn.SessionData) { - s.mu.Lock() - s.sessions[session.Challenge] = *session - s.mu.Unlock() -} - -func (s *Service) takeSession(challenge string) (webauthn.SessionData, bool) { - s.mu.Lock() - sess, ok := s.sessions[challenge] - if ok { - delete(s.sessions, challenge) - } - s.mu.Unlock() - // Expires 可能为零值:go-webauthn 默认 Enforce=false 不设过期时间。 - // 零值时间恒早于 now,直接 After 会把每个 challenge 都判为过期, - // 与库内部一致,仅当显式设置了过期时间才做校验。 - if ok && !sess.Expires.IsZero() && time.Now().After(sess.Expires) { - return webauthn.SessionData{}, false - } - return sess, ok -} diff --git a/backend/router/setRouter.go b/backend/router/setRouter.go index c19cd4f..54cee44 100644 --- a/backend/router/setRouter.go +++ b/backend/router/setRouter.go @@ -22,6 +22,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -64,10 +65,19 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) { gateway.SetUsageRecorder(usageRecorder) // Initialize passkey service + var rdb *redis.Client + if cfg.RedisHost != "" { + rdb = redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", cfg.RedisHost, cfg.RedisPort), + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + }) + } passkeySvc, err := passkey.New(db, passkey.Config{ RPID: cfg.RPID, Origins: cfg.RPOrigins, Name: cfg.AppName, + Redis: rdb, }) if err != nil { log.Fatalf("Failed to initialize passkey service: %v", err)