Files
new-api/model/user_auth_cache.go
T
Calcium-Ion 31d70fca39 refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
2026-07-20 16:48:43 +08:00

284 lines
8.9 KiB
Go

package model
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// User auth cache fencing uses three Redis keys per user: the cached user
// hash, a short-lived pending fence published before a restrictive database
// transaction, and a monotonic committed version floor published after
// commit. Cache writes below either floor are rejected, readers below the
// effective floor fall back to the database, and the pending fence outlives
// every user-hash TTL so a rolled-back transaction heals without allowing a
// stale snapshot to re-authorize the user.
var ErrUserAuthCachePending = errors.New("user authentication state update is pending")
var ErrUserAuthVersionConflict = errors.New("user authentication version update conflicted")
func getUserAuthFenceKey(userId int) string {
return fmt.Sprintf("auth:user:fence:%d", userId)
}
func getUserAuthVersionKey(userId int) string {
return fmt.Sprintf("auth:user:version:%d", userId)
}
// A pending fence only covers the interval between publishing the next
// version and the surrounding database transaction reaching a decision. Its
// TTL must outlive every user hash that could have been populated before the
// fence, while still allowing an automatically rolled-back transaction to
// recover without an operator repairing Redis.
func userAuthFenceTTLSeconds() int {
cacheTTL := userCacheTTLSeconds()
extra := cacheTTL
if extra < 60 {
extra = 60
}
return cacheTTL + extra
}
func writeUserCache(user *UserBase, includeQuota bool) error {
if user == nil || user.Id <= 0 || !common.RedisEnabled {
return nil
}
user.CacheSchema = userCacheSchemaVersion
if user.AuthVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
includeQuotaArg := "0"
if includeQuota {
includeQuotaArg = "1"
}
ttl := userCacheTTLSeconds()
const script = `
local incoming = tonumber(ARGV[1])
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
local committed = tonumber(redis.call('GET', KEYS[3]) or '0')
local current = tonumber(redis.call('HGET', KEYS[1], 'AuthVersion') or '0')
if pending > incoming or committed > incoming or current > incoming then
return 0
end
if committed < incoming then
redis.call('SET', KEYS[3], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
if ARGV[10] == '0' and redis.call('EXISTS', KEYS[1]) == 0 then
return 1
end
redis.call('HSET', KEYS[1],
'Id', ARGV[2], 'Group', ARGV[3], 'Email', ARGV[4],
'Status', ARGV[5], 'Role', ARGV[6], 'Username', ARGV[7],
'Setting', ARGV[8], 'AuthVersion', ARGV[1], 'CacheSchema', ARGV[9])
if ARGV[10] == '1' and redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
redis.call('HSET', KEYS[1], 'Quota', ARGV[11])
end
redis.call('EXPIRE', KEYS[1], ARGV[12])
return 1`
result, err := common.RDB.Eval(context.Background(), script,
[]string{getUserCacheKey(user.Id), getUserAuthFenceKey(user.Id), getUserAuthVersionKey(user.Id)},
user.AuthVersion, user.Id, user.Group, user.Email, user.Status, user.Role,
user.Username, user.Setting, user.CacheSchema, includeQuotaArg, user.Quota, ttl,
).Int()
if err != nil {
return err
}
if result == 0 {
return ErrUserAuthCachePending
}
return nil
}
func getUserAuthVersionFloor(userId int) (int64, error) {
if !common.RedisEnabled {
return 0, nil
}
values, err := common.RDB.MGet(context.Background(), getUserAuthFenceKey(userId), getUserAuthVersionKey(userId)).Result()
if err != nil {
return 0, err
}
var floor int64
for _, value := range values {
if value == nil {
continue
}
parsed, err := strconv.ParseInt(fmt.Sprint(value), 10, 64)
if err != nil {
return 0, err
}
if parsed > floor {
floor = parsed
}
}
return floor, nil
}
// SetUserAuthVersionFence publishes a fail-closed version before a restrictive
// database update. Pending fences expire only after every pre-existing user
// hash must have expired; a committed update is promoted separately to a
// permanent monotonic version floor.
func SetUserAuthVersionFence(userId int, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid user auth fence")
}
const script = `
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local incoming = tonumber(ARGV[1])
if current < incoming then
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
elseif current == incoming then
redis.call('EXPIRE', KEYS[1], ARGV[2])
elseif redis.call('TTL', KEYS[1]) < 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return 1`
return common.RDB.Eval(context.Background(), script, []string{getUserAuthFenceKey(userId)}, authVersion, userAuthFenceTTLSeconds()).Err()
}
// publishCommittedUserAuthVersion records the durable lower bound used to
// reject an arbitrarily delayed cache fill after a committed security change.
// It also removes this transaction's now-obsolete pending fence.
func publishCommittedUserAuthVersion(userId int, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid committed user auth version")
}
const script = `
local incoming = tonumber(ARGV[1])
local committed = tonumber(redis.call('GET', KEYS[1]) or '0')
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
if committed < incoming then
redis.call('SET', KEYS[1], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
return 1`
return common.RDB.Eval(context.Background(), script,
[]string{getUserAuthVersionKey(userId), getUserAuthFenceKey(userId)}, authVersion,
).Err()
}
// IncrementUserAuthVersionWithTx locks the user, publishes the next deny
// fence, then persists the version in the caller's transaction. Unscoped is
// intentional so the same fail-closed path also covers hard deletion of an
// already soft-deleted user.
func IncrementUserAuthVersionWithTx(tx *gorm.DB, userId int) (int64, error) {
if tx == nil || userId <= 0 {
return 0, fmt.Errorf("invalid user auth version update")
}
for range 3 {
var user User
if err := lockForUpdate(tx.Unscoped()).Select("id", "auth_version").Where("id = ?", userId).First(&user).Error; err != nil {
return 0, err
}
current := user.AuthVersion
if current < 1 {
current = 1
}
next := current + 1
if err := SetUserAuthVersionFence(userId, next); err != nil {
return 0, err
}
result := tx.Unscoped().Model(&User{}).
Where("id = ? AND auth_version = ?", userId, user.AuthVersion).
Update("auth_version", next)
if result.Error != nil {
return 0, result.Error
}
if result.RowsAffected == 1 {
return next, nil
}
}
return 0, ErrUserAuthVersionConflict
}
// BumpUserAuthVersion is the transaction-owning variant used by password,
// role, status and security-factor changes outside another transaction.
func BumpUserAuthVersion(userId int) (int64, error) {
var next int64
if err := DB.Transaction(func(tx *gorm.DB) error {
var err error
next, err = IncrementUserAuthVersionWithTx(tx, userId)
return err
}); err != nil {
return 0, err
}
if err := PublishUserAuthCache(userId); err != nil {
return next, err
}
return next, nil
}
// PublishUserAuthCache refreshes the current database state after a successful
// auth-sensitive transaction without touching the cached quota field.
func PublishUserAuthCache(userId int) error {
user, err := GetUserById(userId, false)
if err != nil {
return err
}
return updateUserCache(*user)
}
// InitializeUserAuthVersions must run after AutoMigrate when upgrading an
// existing database. It is idempotent and portable across all supported DBs.
func InitializeUserAuthVersions() error {
return DB.Model(&User{}).Where("auth_version IS NULL OR auth_version < ?", 1).Update("auth_version", 1).Error
}
func updateUserCacheFieldAtVersion(userId int, field string, value interface{}, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
const script = `
local incoming = tonumber(ARGV[1])
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
local committed = tonumber(redis.call('GET', KEYS[3]) or '0')
local current = tonumber(redis.call('HGET', KEYS[1], 'AuthVersion') or '0')
if pending > incoming or committed > incoming or current > incoming then
return 0
end
if committed < incoming then
redis.call('SET', KEYS[3], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
if redis.call('EXISTS', KEYS[1]) == 0 then
return 1
end
if current ~= incoming then
return 1
end
redis.call('HSET', KEYS[1], ARGV[2], ARGV[3], 'CacheSchema', ARGV[4])
return 1`
result, err := common.RDB.Eval(context.Background(), script,
[]string{getUserCacheKey(userId), getUserAuthFenceKey(userId), getUserAuthVersionKey(userId)},
authVersion, field, value, userCacheSchemaVersion,
).Int()
if err != nil {
return err
}
if result == 0 {
return ErrUserAuthCachePending
}
return nil
}