Files
geo/server/internal/shared/auth/session_store.go
T
root e045e00fbf
Frontend CI / Frontend (push) Successful in 3m8s
Backend CI / Backend (push) Successful in 14m48s
feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
Add self-service password change that re-hashes the credential and bumps a
per-user session version so all existing access/refresh tokens are rejected.
Session version is tracked in Redis with an in-memory fallback and enforced in
middleware and refresh.

Scope prompt rules and rule groups to a brand: new brand_id column (migrated to
a "未归属" fallback brand for existing rows), brand-filtered queries/indexes, and
brand-aware admin-web tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:09:53 +08:00

313 lines
7.0 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
var (
ErrRefreshSessionNotFound = errors.New("refresh session not found")
ErrRefreshTokenMismatch = errors.New("refresh token mismatch")
)
var rotateRefreshScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if not current then
return 0
end
if current ~= ARGV[1] then
return -1
end
redis.call("DEL", KEYS[1])
redis.call("PSETEX", KEYS[2], ARGV[3], ARGV[2])
return 1
`)
var setMaxSessionVersionScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
local current_number = tonumber(current or "0") or 0
local next_number = tonumber(ARGV[1]) or 0
if current_number >= next_number then
return current_number
end
redis.call("SET", KEYS[1], ARGV[1])
return next_number
`)
const sessionVersionFallbackTTL = 30 * 24 * time.Hour
type SessionStore struct {
rdb *redis.Client
fallback *memorySessionStore
}
func NewSessionStore(rdb *redis.Client) *SessionStore {
return &SessionStore{
rdb: rdb,
fallback: newMemorySessionStore(),
}
}
func (s *SessionStore) SaveRefresh(ctx context.Context, jti string, tokenHash string, ttl time.Duration) error {
key := fmt.Sprintf("refresh:%s", jti)
s.fallback.set(key, tokenHash, ttl)
if s.rdb == nil {
return nil
}
if err := s.rdb.Set(ctx, key, tokenHash, ttl).Err(); err != nil {
return nil
}
return nil
}
func (s *SessionStore) GetRefresh(ctx context.Context, jti string) (string, error) {
key := fmt.Sprintf("refresh:%s", jti)
if s.rdb != nil {
value, err := s.rdb.Get(ctx, key).Result()
if err == nil {
return value, nil
}
}
return s.fallback.get(key)
}
func (s *SessionStore) DeleteRefresh(ctx context.Context, jti string) error {
key := fmt.Sprintf("refresh:%s", jti)
s.fallback.delete(key)
if s.rdb == nil {
return nil
}
if err := s.rdb.Del(ctx, key).Err(); err != nil {
return nil
}
return nil
}
func (s *SessionStore) RotateRefresh(ctx context.Context, oldJTI, oldHash, newJTI, newHash string, ttl time.Duration) error {
oldKey := fmt.Sprintf("refresh:%s", oldJTI)
newKey := fmt.Sprintf("refresh:%s", newJTI)
if s.rdb == nil {
return s.fallback.rotate(oldKey, oldHash, newKey, newHash, ttl)
}
result, err := rotateRefreshScript.Run(
ctx,
s.rdb,
[]string{oldKey, newKey},
oldHash,
newHash,
ttl.Milliseconds(),
).Int()
if err != nil {
return s.fallback.rotate(oldKey, oldHash, newKey, newHash, ttl)
}
switch result {
case 1:
_ = s.fallback.rotate(oldKey, oldHash, newKey, newHash, ttl)
return nil
case 0:
if err := s.fallback.rotate(oldKey, oldHash, newKey, newHash, ttl); err == nil {
return nil
}
return ErrRefreshSessionNotFound
case -1:
return ErrRefreshTokenMismatch
default:
return fmt.Errorf("unexpected rotate refresh result: %d", result)
}
}
func (s *SessionStore) Blacklist(ctx context.Context, accessJTI string, ttl time.Duration) error {
key := fmt.Sprintf("blacklist:%s", accessJTI)
s.fallback.set(key, "1", ttl)
if s.rdb == nil {
return nil
}
if err := s.rdb.Set(ctx, key, "1", ttl).Err(); err != nil {
return nil
}
return nil
}
func (s *SessionStore) IsBlacklisted(ctx context.Context, accessJTI string) (bool, error) {
key := fmt.Sprintf("blacklist:%s", accessJTI)
if _, err := s.fallback.get(key); err == nil {
return true, nil
}
if s.rdb == nil {
return false, nil
}
n, err := s.rdb.Exists(ctx, key).Result()
if err != nil {
return false, nil
}
return n > 0, nil
}
func (s *SessionStore) SessionVersion(ctx context.Context, userID int64) (int64, error) {
if s == nil {
return 0, nil
}
key := sessionVersionKey(userID)
fallbackVersion := s.fallback.sessionVersion(key)
redisVersion := int64(0)
if s.rdb != nil {
value, err := s.rdb.Get(ctx, key).Result()
if err == nil {
version, parseErr := strconv.ParseInt(value, 10, 64)
if parseErr == nil && version > 0 {
redisVersion = version
}
}
}
version := maxInt64(redisVersion, fallbackVersion)
if version <= 0 {
return 0, nil
}
s.fallback.set(key, strconv.FormatInt(version, 10), sessionVersionFallbackTTL)
if s.rdb != nil && version > redisVersion {
_, _ = setMaxSessionVersionScript.Run(ctx, s.rdb, []string{key}, strconv.FormatInt(version, 10)).Int64()
}
return version, nil
}
func (s *SessionStore) BumpSessionVersion(ctx context.Context, userID int64) (int64, error) {
if s == nil {
return 0, nil
}
key := sessionVersionKey(userID)
current, _ := s.SessionVersion(ctx, userID)
next := current + 1
s.fallback.set(key, strconv.FormatInt(next, 10), sessionVersionFallbackTTL)
if s.rdb == nil {
return next, nil
}
version, err := s.rdb.Incr(ctx, key).Result()
if err != nil {
return next, nil
}
if version < next {
if maxVersion, maxErr := setMaxSessionVersionScript.Run(ctx, s.rdb, []string{key}, strconv.FormatInt(next, 10)).Int64(); maxErr == nil {
version = maxVersion
}
}
s.fallback.set(key, strconv.FormatInt(version, 10), sessionVersionFallbackTTL)
return version, nil
}
func sessionVersionKey(userID int64) string {
return fmt.Sprintf("session_version:user:%d", userID)
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
type memorySessionStore struct {
mu sync.Mutex
items map[string]memorySessionItem
}
type memorySessionItem struct {
value string
expiresAt time.Time
}
func newMemorySessionStore() *memorySessionStore {
return &memorySessionStore{items: make(map[string]memorySessionItem)}
}
func (s *memorySessionStore) set(key, value string, ttl time.Duration) {
if s == nil || ttl <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.items[key] = memorySessionItem{
value: value,
expiresAt: time.Now().Add(ttl),
}
}
func (s *memorySessionStore) get(key string) (string, error) {
if s == nil {
return "", redis.Nil
}
s.mu.Lock()
defer s.mu.Unlock()
item, ok := s.items[key]
if !ok {
return "", redis.Nil
}
if !item.expiresAt.After(time.Now()) {
delete(s.items, key)
return "", redis.Nil
}
return item.value, nil
}
func (s *memorySessionStore) delete(key string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
delete(s.items, key)
}
func (s *memorySessionStore) sessionVersion(key string) int64 {
if s == nil {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
if item, ok := s.items[key]; ok && item.expiresAt.After(time.Now()) {
if parsed, err := strconv.ParseInt(item.value, 10, 64); err == nil && parsed > 0 {
return parsed
}
}
return 0
}
func (s *memorySessionStore) rotate(oldKey, oldHash, newKey, newHash string, ttl time.Duration) error {
if s == nil {
return ErrRefreshSessionNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
item, ok := s.items[oldKey]
if !ok {
return ErrRefreshSessionNotFound
}
if !item.expiresAt.After(time.Now()) {
delete(s.items, oldKey)
return ErrRefreshSessionNotFound
}
if item.value != oldHash {
return ErrRefreshTokenMismatch
}
delete(s.items, oldKey)
if ttl > 0 {
s.items[newKey] = memorySessionItem{
value: newHash,
expiresAt: time.Now().Add(ttl),
}
}
return nil
}