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
This commit is contained in:
Sakurasan
2026-09-01 23:03:39 +08:00
parent 9733b3c20b
commit a376ac0722
5 changed files with 157 additions and 46 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ DB_DSN=
DB_MAX_OPEN_CONNS=10 DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5 DB_MAX_IDLE_CONNS=5
# --- Redis 配置(可选)--- # --- Redis 配置(可选,用于分布式 passkey session)---
# REDIS_HOST=localhost # REDIS_HOST=localhost
# REDIS_PORT=6379 # REDIS_PORT=6379
# REDIS_PASSWORD= # REDIS_PASSWORD=
+3
View File
@@ -27,6 +27,7 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // 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/cloudwego/base64x v0.1.5 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // 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/go-sqlite3-wasm/v2 v2.1.35300 // indirect
github.com/ncruces/julianday v1.0.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // 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/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // 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/arch v0.16.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
+6
View File
@@ -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.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 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 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 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 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= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 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= 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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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= 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 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= 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= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+136 -44
View File
@@ -3,8 +3,10 @@ package passkey
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
@@ -13,23 +15,112 @@ import (
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/redis/go-redis/v9"
"opencatd-open/internal/store" "opencatd-open/internal/store"
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
sessionPrefix = "passkey:session:"
sessionTTL = 5 * time.Minute
)
type Config struct { type Config struct {
RPID string RPID string
Origins []string Origins []string
Name string Name string
Redis *redis.Client // 可选,nil 时使用内存存储
} }
// Service WebAuthn 服务:凭据存储 + challenge 会话(内存)。 // 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
}
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 { type Service struct {
wa *webauthn.WebAuthn wa *webauthn.WebAuthn
db *gorm.DB db *gorm.DB
sessions SessionStore
mu sync.Mutex
sessions map[string]webauthn.SessionData // keyed by challenge
} }
func New(db *gorm.DB, cfg Config) (*Service, error) { 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 { if err != nil {
return nil, err 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 接口。 // webUser 实现 go-webauthn 的 User 接口。
@@ -102,22 +202,30 @@ func (s *Service) BeginRegistration(u *store.User) (*protocol.CredentialCreation
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.storeSession(session) if err := s.sessions.Set(context.Background(), session); err != nil {
return nil, err
}
return creation, nil return creation, nil
} }
// FinishRegistration 校验浏览器返回的凭据并落库。 // FinishRegistration 校验浏览器返回的凭据并落库。
func (s *Service) FinishRegistration(u *store.User, challenge string, body []byte, name string) error { 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 { if !ok {
return errors.New("challenge 已过期或不存在") return errors.New("challenge 已过期或不存在")
} }
// 删除已使用的 challenge
_ = s.sessions.Delete(context.Background(), challenge)
wu, err := s.loadWebUser(u) wu, err := s.loadWebUser(u)
if err != nil { if err != nil {
return err return err
} }
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) 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 { if err != nil {
return err return err
} }
@@ -144,7 +252,9 @@ func (s *Service) BeginLogin(u *store.User) (*protocol.CredentialAssertion, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.storeSession(session) if err := s.sessions.Set(context.Background(), session); err != nil {
return nil, err
}
return assertion, nil return assertion, nil
} }
@@ -154,22 +264,29 @@ func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.storeSession(session) if err := s.sessions.Set(context.Background(), session); err != nil {
return nil, err
}
return assertion, nil return assertion, nil
} }
// FinishLogin 校验断言并更新签名计数。 // FinishLogin 校验断言并更新签名计数。
func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error { 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 { if !ok {
return errors.New("challenge 已过期或不存在") return errors.New("challenge 已过期或不存在")
} }
_ = s.sessions.Delete(context.Background(), challenge)
wu, err := s.loadWebUser(u) wu, err := s.loadWebUser(u)
if err != nil { if err != nil {
return err return err
} }
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) 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 { if err != nil {
return err return err
} }
@@ -178,10 +295,15 @@ func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) erro
// FinishDiscoverableLogin 通过凭据定位用户并校验断言。 // FinishDiscoverableLogin 通过凭据定位用户并校验断言。
func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store.User, error) { 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 { if !ok {
return nil, errors.New("challenge 已过期或不存在") return nil, errors.New("challenge 已过期或不存在")
} }
_ = s.sessions.Delete(context.Background(), challenge)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
// 先通过 credential_id 反查用户 // 先通过 credential_id 反查用户
@@ -205,13 +327,8 @@ func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store
if err != nil { if err != nil {
continue continue
} }
cred, err := s.wa.FinishLogin(wu, session, req) cred, err := s.wa.FinishLogin(wu, *session, req)
if err != nil { if err != nil {
// 不是这个用户的 passkey,继续尝试
session, _ = s.takeSession(challenge)
if !ok {
return nil, errors.New("challenge 已过期或不存在")
}
continue continue
} }
_ = s.updateCredential(u.ID, cred) _ = 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). Where("user_id = ? AND credential_id = ?", userID, cred.ID).
Update("credential", raw).Error 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
}
+10
View File
@@ -22,6 +22,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -64,10 +65,19 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
gateway.SetUsageRecorder(usageRecorder) gateway.SetUsageRecorder(usageRecorder)
// Initialize passkey service // 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{ passkeySvc, err := passkey.New(db, passkey.Config{
RPID: cfg.RPID, RPID: cfg.RPID,
Origins: cfg.RPOrigins, Origins: cfg.RPOrigins,
Name: cfg.AppName, Name: cfg.AppName,
Redis: rdb,
}) })
if err != nil { if err != nil {
log.Fatalf("Failed to initialize passkey service: %v", err) log.Fatalf("Failed to initialize passkey service: %v", err)