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>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +29,19 @@ 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
|
||||
@@ -138,6 +152,70 @@ func (s *SessionStore) IsBlacklisted(ctx context.Context, accessJTI string) (boo
|
||||
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
|
||||
@@ -190,6 +268,21 @@ func (s *memorySessionStore) delete(key string) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user