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:
@@ -9,5 +9,6 @@ type Claims struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
PrimaryWorkspaceID int64 `json:"primary_workspace_id"`
|
||||
Role string `json:"role"`
|
||||
SessionVersion int64 `json:"session_version,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ type TokenPair struct {
|
||||
}
|
||||
|
||||
func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) (*TokenPair, error) {
|
||||
return m.IssueWithSessionVersion(userID, tenantID, primaryWorkspaceID, role, 0)
|
||||
}
|
||||
|
||||
func (m *Manager) IssueWithSessionVersion(userID, tenantID, primaryWorkspaceID int64, role string, sessionVersion int64) (*TokenPair, error) {
|
||||
secret, accessTTL, refreshTTL := m.snapshot()
|
||||
now := time.Now()
|
||||
accessJTI := uuid.New().String()
|
||||
@@ -53,6 +57,7 @@ func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string)
|
||||
TenantID: tenantID,
|
||||
PrimaryWorkspaceID: primaryWorkspaceID,
|
||||
Role: role,
|
||||
SessionVersion: sessionVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: accessJTI,
|
||||
Subject: "access",
|
||||
@@ -70,6 +75,7 @@ func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string)
|
||||
TenantID: tenantID,
|
||||
PrimaryWorkspaceID: primaryWorkspaceID,
|
||||
Role: role,
|
||||
SessionVersion: sessionVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: refreshJTI,
|
||||
Subject: "refresh",
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestIssueAndParse(t *testing.T) {
|
||||
assert.Equal(t, "tenant_admin", claims.Role)
|
||||
assert.Equal(t, "access", claims.Subject)
|
||||
assert.Equal(t, pair.AccessJTI, claims.ID)
|
||||
assert.Equal(t, int64(0), claims.SessionVersion)
|
||||
}
|
||||
|
||||
func TestParseRefreshToken(t *testing.T) {
|
||||
@@ -41,6 +42,21 @@ func TestParseRefreshToken(t *testing.T) {
|
||||
assert.Equal(t, pair.RefreshJTI, claims.ID)
|
||||
}
|
||||
|
||||
func TestIssueWithSessionVersion(t *testing.T) {
|
||||
mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
pair, err := mgr.IssueWithSessionVersion(1, 1, 10, "viewer", 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
accessClaims, err := mgr.Parse(pair.AccessToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), accessClaims.SessionVersion)
|
||||
|
||||
refreshClaims, err := mgr.Parse(pair.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), refreshClaims.SessionVersion)
|
||||
}
|
||||
|
||||
func TestParseExpired(t *testing.T) {
|
||||
mgr := NewManager("test-secret-key", -1*time.Second, -1*time.Second)
|
||||
|
||||
|
||||
@@ -75,6 +75,17 @@ func Middleware(jwtMgr *Manager, sessions *SessionStore) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
currentVersion, _ := sessions.SessionVersion(c.Request.Context(), claims.UserID)
|
||||
if claims.SessionVersion < currentVersion {
|
||||
updateRequestLogContext(c, func(meta *RequestLogContext) {
|
||||
meta.FailureStage = "check_session_version"
|
||||
meta.FailureReason = "session_version_stale"
|
||||
})
|
||||
response.Error(c, response.ErrUnauthorized(40103, "token_revoked", "token has been revoked"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
revoked, _ := sessions.IsBlacklisted(c.Request.Context(), claims.ID)
|
||||
if revoked {
|
||||
updateRequestLogContext(c, func(meta *RequestLogContext) {
|
||||
|
||||
@@ -123,3 +123,23 @@ func TestAuthMiddleware_BlacklistedToken(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_StaleSessionVersionRejected(t *testing.T) {
|
||||
mgr, sessions, _ := setupTestMiddleware(t)
|
||||
|
||||
pair, err := mgr.IssueWithSessionVersion(1, 10, 100, "editor", 0)
|
||||
require.NoError(t, err)
|
||||
_, err = sessions.BumpSessionVersion(t.Context(), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(Middleware(mgr, sessions))
|
||||
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,3 +85,43 @@ func TestSessionStoreUsesMemoryFallbackWhenRedisUnavailable(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, revoked)
|
||||
}
|
||||
|
||||
func TestSessionStoreBumpSessionVersion(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store := NewSessionStore(client)
|
||||
ctx := context.Background()
|
||||
|
||||
version, err := store.SessionVersion(ctx, 42)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), version)
|
||||
|
||||
version, err = store.BumpSessionVersion(ctx, 42)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), version)
|
||||
|
||||
version, err = store.SessionVersion(ctx, 42)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), version)
|
||||
}
|
||||
|
||||
func TestSessionStoreSessionVersionKeepsFallbackBumpWhenRedisLags(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store := NewSessionStore(client)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, mr.Set(sessionVersionKey(42), "1"))
|
||||
store.fallback.set(sessionVersionKey(42), "3", sessionVersionFallbackTTL)
|
||||
|
||||
version, err := store.SessionVersion(ctx, 42)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), version)
|
||||
stored, err := mr.Get(sessionVersionKey(42))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "3", stored)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ var routeDocs = map[string]routeDoc{
|
||||
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
||||
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
|
||||
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
|
||||
"POST /api/auth/password": {"修改密码", "当前登录用户修改自己的密码,成功后吊销该用户既有登录会话。"},
|
||||
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
|
||||
|
||||
// --- Public 资源 ---
|
||||
|
||||
Reference in New Issue
Block a user