forked from QuantumNous/new-api
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuser_auth_cache.go
More file actions
283 lines (265 loc) · 8.87 KB
/
Copy pathuser_auth_cache.go
File metadata and controls
283 lines (265 loc) · 8.87 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
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
}