feat: back DeviceAuthMap with Redis for multi-replica deployments
This commit is contained in:
@@ -66,6 +66,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/qiangmzsx/string-adapter/v2 v2.1.0
|
||||
github.com/redis/go-redis/v9 v9.5.5
|
||||
github.com/resend/resend-go/v3 v3.1.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/russellhaering/gosaml2 v0.9.0
|
||||
@@ -237,7 +238,6 @@ require (
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/qiniu/go-sdk/v7 v7.12.1 // indirect
|
||||
github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 // indirect
|
||||
github.com/redis/go-redis/v9 v9.5.5 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/rs/zerolog v1.33.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
"github.com/casdoor/casdoor/conf"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// deviceAuthStore mirrors the sync.Map methods used by the device authorization flow.
|
||||
// The default implementation is in-memory; when redisEndpoint is configured, a Redis-backed
|
||||
// implementation is used so the flow works correctly across multiple Casdoor replicas.
|
||||
type deviceAuthStore interface {
|
||||
Load(key any) (any, bool)
|
||||
Store(key, value any)
|
||||
Delete(key any)
|
||||
LoadAndDelete(key any) (any, bool)
|
||||
Range(f func(key, value any) bool)
|
||||
}
|
||||
|
||||
const deviceAuthRedisPrefix = "casdoor:device_auth:"
|
||||
|
||||
// InitDeviceAuthStore switches DeviceAuthMap to a Redis-backed store when redisEndpoint is
|
||||
// configured. It must be called after configuration is loaded and before serving requests.
|
||||
// On failure it logs a warning and keeps the default in-memory store.
|
||||
func InitDeviceAuthStore() {
|
||||
endpoint := conf.GetConfigString("redisEndpoint")
|
||||
if endpoint == "" {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := newRedisClient(endpoint)
|
||||
if err != nil {
|
||||
logs.Warn("device_auth_store: failed to connect to Redis (%s), falling back to in-memory store: %v", endpoint, err)
|
||||
return
|
||||
}
|
||||
|
||||
DeviceAuthMap = &redisDeviceAuthStore{client: client}
|
||||
logs.Info("device_auth_store: using Redis backend at %s", endpoint)
|
||||
}
|
||||
|
||||
// newRedisClient parses the same "host:port[,db[,password]]" format that the beego session
|
||||
// Redis provider uses, so users do not need a separate configuration key.
|
||||
func newRedisClient(endpoint string) (*redis.Client, error) {
|
||||
addr := endpoint
|
||||
db := 0
|
||||
password := ""
|
||||
|
||||
if i := strings.Index(endpoint, ","); i >= 0 {
|
||||
addr = endpoint[:i]
|
||||
rest := endpoint[i+1:]
|
||||
parts := strings.SplitN(rest, ",", 2)
|
||||
if d, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
|
||||
db = d
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
password = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
DB: db,
|
||||
Password: password,
|
||||
})
|
||||
|
||||
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ── in-memory implementation (default) ──────────────────────────────────────
|
||||
|
||||
type memoryDeviceAuthStore struct {
|
||||
m sync.Map
|
||||
}
|
||||
|
||||
func (s *memoryDeviceAuthStore) Load(key any) (any, bool) { return s.m.Load(key) }
|
||||
func (s *memoryDeviceAuthStore) Store(key, value any) { s.m.Store(key, value) }
|
||||
func (s *memoryDeviceAuthStore) Delete(key any) { s.m.Delete(key) }
|
||||
func (s *memoryDeviceAuthStore) LoadAndDelete(key any) (any, bool) { return s.m.LoadAndDelete(key) }
|
||||
func (s *memoryDeviceAuthStore) Range(f func(key, value any) bool) { s.m.Range(f) }
|
||||
|
||||
// ── Redis implementation ─────────────────────────────────────────────────────
|
||||
|
||||
type redisDeviceAuthStore struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func (s *redisDeviceAuthStore) redisKey(key any) (string, bool) {
|
||||
k, ok := key.(string)
|
||||
return deviceAuthRedisPrefix + k, ok
|
||||
}
|
||||
|
||||
func (s *redisDeviceAuthStore) Load(key any) (any, bool) {
|
||||
rk, ok := s.redisKey(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
data, err := s.client.Get(context.Background(), rk).Bytes()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var cache DeviceAuthCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return cache, true
|
||||
}
|
||||
|
||||
func (s *redisDeviceAuthStore) Store(key, value any) {
|
||||
rk, ok := s.redisKey(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
cache, ok := value.(DeviceAuthCache)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cache)
|
||||
if err != nil {
|
||||
logs.Warn("device_auth_store: failed to marshal DeviceAuthCache: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ttl := cache.ExpiresIn
|
||||
if ttl <= 0 {
|
||||
ttl = DeviceAuthExpiresIn
|
||||
}
|
||||
|
||||
if err := s.client.Set(context.Background(), rk, data, time.Duration(ttl)*time.Second).Err(); err != nil {
|
||||
logs.Warn("device_auth_store: Redis SET failed for key %s: %v", rk, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisDeviceAuthStore) Delete(key any) {
|
||||
rk, ok := s.redisKey(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.client.Del(context.Background(), rk).Err(); err != nil {
|
||||
logs.Warn("device_auth_store: Redis DEL failed for key %s: %v", rk, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisDeviceAuthStore) LoadAndDelete(key any) (any, bool) {
|
||||
rk, ok := s.redisKey(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetDel is atomic and available since Redis 6.2. For older Redis the worst case is a
|
||||
// small window between GET and DEL where a concurrent request sees the same entry; that
|
||||
// is acceptable given the short device-auth TTL.
|
||||
data, err := s.client.GetDel(context.Background(), rk).Bytes()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var cache DeviceAuthCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return cache, true
|
||||
}
|
||||
|
||||
// Range is a no-op for the Redis backend: entries expire automatically via TTL,
|
||||
// so the periodic sweep in InitCleanupDeviceAuthMap is not needed.
|
||||
func (s *redisDeviceAuthStore) Range(_ func(key, value any) bool) {}
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/i18n"
|
||||
@@ -48,7 +47,10 @@ const (
|
||||
DeviceAuthStatusTokenIssued = "token_issued"
|
||||
)
|
||||
|
||||
var DeviceAuthMap = sync.Map{}
|
||||
// DeviceAuthMap stores the transient state of the OAuth 2.0 Device Authorization Grant (RFC 8628).
|
||||
// It defaults to an in-memory store; call InitDeviceAuthStore() at startup to switch to Redis
|
||||
// when redisEndpoint is configured, enabling correct behaviour across multiple replicas.
|
||||
var DeviceAuthMap deviceAuthStore = &memoryDeviceAuthStore{}
|
||||
|
||||
type Code struct {
|
||||
Message string `xorm:"varchar(100)" json:"message"`
|
||||
@@ -103,6 +105,7 @@ type DeviceAuthCache struct {
|
||||
}
|
||||
|
||||
func InitCleanupDeviceAuthMap() {
|
||||
InitDeviceAuthStore()
|
||||
util.SafeGoroutine(func() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
Reference in New Issue
Block a user