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:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user