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
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+236
View File
@@ -0,0 +1,236 @@
package model
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
AuthFlowPurposeOAuth = "oauth"
AuthFlowPurposeTwoFALogin = "2fa_login"
AuthFlowPurposePasskeyLogin = "passkey_login"
AuthFlowPurposePasskeyRegister = "passkey_register"
AuthFlowPurposePasskeyStepUp = "passkey_step_up"
AuthFlowPurposeTelegramBind = "telegram_bind"
AuthFlowPurposeTelegramAssertion = "telegram_assertion"
AuthFlowIntentLogin = "login"
AuthFlowIntentBind = "bind"
AuthFlowTokenBytes = 32
AuthFlowDefaultCleanupRetention = 24 * time.Hour
)
var (
ErrAuthFlowInvalid = errors.New("auth flow is invalid")
ErrAuthFlowExpired = errors.New("auth flow has expired")
ErrAuthFlowConsumed = errors.New("auth flow has already been consumed")
)
// AuthFlow stores one-time, short-lived state for authentication ceremonies.
// TokenHash is an HMAC of the opaque token; the token itself is never persisted.
type AuthFlow struct {
Id int64 `json:"id" gorm:"primaryKey"`
TokenHash string `json:"-" gorm:"type:char(64);not null;uniqueIndex"`
Purpose string `json:"purpose" gorm:"type:varchar(32);not null;index:idx_auth_flow_purpose_expiry"`
Provider string `json:"provider,omitempty" gorm:"type:varchar(64)"`
Intent string `json:"intent,omitempty" gorm:"type:varchar(16)"`
UserId int `json:"user_id,omitempty" gorm:"index"`
SessionId string `json:"session_id,omitempty" gorm:"type:varchar(64);index"`
Payload string `json:"-" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_auth_flow_purpose_expiry"`
ConsumedAt *time.Time `json:"consumed_at,omitempty" gorm:"index"`
}
func (AuthFlow) TableName() string {
return "auth_flows"
}
type AuthFlowCreate struct {
Purpose string
Provider string
Intent string
UserId int
SessionId string
Payload string
ExpiresAt time.Time
}
type AuthFlowMatch struct {
Purpose string
Provider string
Intent string
UserId int
SessionId string
}
func applyAuthFlowMatch(query *gorm.DB, token string, match AuthFlowMatch) *gorm.DB {
query = query.Where("token_hash = ? AND purpose = ?", authFlowTokenHash(token), match.Purpose)
if match.Provider != "" {
query = query.Where("provider = ?", match.Provider)
}
if match.Intent != "" {
query = query.Where("intent = ?", match.Intent)
}
if match.UserId != 0 {
query = query.Where("user_id = ?", match.UserId)
}
if match.SessionId != "" {
query = query.Where("session_id = ?", match.SessionId)
}
return query
}
func authFlowTokenHash(token string) string {
return common.GenerateHMACWithKey([]byte("auth-flow-v1:"+common.SessionSecret), token)
}
func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) {
if strings.TrimSpace(input.Purpose) == "" || input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) {
return "", nil, ErrAuthFlowInvalid
}
random := make([]byte, AuthFlowTokenBytes)
if _, err := rand.Read(random); err != nil {
return "", nil, fmt.Errorf("generate auth flow token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(random)
flow := &AuthFlow{
TokenHash: authFlowTokenHash(token),
Purpose: input.Purpose,
Provider: input.Provider,
Intent: input.Intent,
UserId: input.UserId,
SessionId: input.SessionId,
Payload: input.Payload,
ExpiresAt: input.ExpiresAt,
}
if err := DB.Create(flow).Error; err != nil {
return "", nil, err
}
return token, flow, nil
}
// ClaimExternalAuthAssertion records a signed provider assertion as consumed.
// The assertion is HMACed before storage and the unique token_hash index makes
// replay rejection atomic on SQLite, MySQL and PostgreSQL.
func ClaimExternalAuthAssertion(purpose, assertion string, expiresAt time.Time) error {
return DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalAuthAssertionWithTx(tx, purpose, assertion, expiresAt)
})
}
// ClaimExternalAuthAssertionWithTx records a provider assertion in the
// caller's transaction so replay protection can commit atomically with the
// authentication flow and its resulting state change.
func ClaimExternalAuthAssertionWithTx(tx *gorm.DB, purpose, assertion string, expiresAt time.Time) error {
purpose = strings.TrimSpace(purpose)
assertion = strings.TrimSpace(assertion)
now := time.Now()
if tx == nil || purpose == "" || assertion == "" || !expiresAt.After(now) {
return ErrAuthFlowInvalid
}
flow := AuthFlow{
TokenHash: authFlowTokenHash("external:" + purpose + ":" + assertion),
Purpose: purpose,
ExpiresAt: expiresAt,
ConsumedAt: &now,
}
result := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "token_hash"}},
DoNothing: true,
}).Create(&flow)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrAuthFlowConsumed
}
return nil
}
// GetAuthFlow validates a flow without consuming it. Callers must still use
// ConsumeAuthFlow with all identity-bound fields before performing the action.
func GetAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) {
if token == "" || match.Purpose == "" {
return nil, ErrAuthFlowInvalid
}
var flow AuthFlow
if err := applyAuthFlowMatch(DB, token, match).First(&flow).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAuthFlowInvalid
}
return nil, err
}
if flow.ConsumedAt != nil {
return nil, ErrAuthFlowConsumed
}
if !flow.ExpiresAt.After(time.Now()) {
return nil, ErrAuthFlowExpired
}
return &flow, nil
}
// ConsumeAuthFlow atomically validates and consumes a flow. Optional match
// fields are enforced when non-zero so tokens cannot cross purposes or users.
func ConsumeAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) {
return ConsumeAuthFlowWithAction(token, match, nil)
}
// ConsumeAuthFlowWithAction consumes a flow and runs action in the same
// database transaction. An action failure rolls the consumption back.
func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx *gorm.DB, flow *AuthFlow) error) (*AuthFlow, error) {
if token == "" || match.Purpose == "" {
return nil, ErrAuthFlowInvalid
}
var consumed AuthFlow
err := DB.Transaction(func(tx *gorm.DB) error {
query := applyAuthFlowMatch(lockForUpdate(tx), token, match)
if err := query.First(&consumed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAuthFlowInvalid
}
return err
}
if consumed.ConsumedAt != nil {
return ErrAuthFlowConsumed
}
now := time.Now()
if !consumed.ExpiresAt.After(now) {
return ErrAuthFlowExpired
}
result := tx.Model(&AuthFlow{}).
Where("id = ? AND consumed_at IS NULL AND expires_at > ?", consumed.Id, now).
Update("consumed_at", now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrAuthFlowConsumed
}
consumed.ConsumedAt = &now
if action != nil {
if err := action(tx, &consumed); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return &consumed, nil
}
func DeleteExpiredAuthFlows(now time.Time) error {
cutoff := now.Add(-AuthFlowDefaultCleanupRetention)
return DB.Where("expires_at < ? OR (consumed_at IS NOT NULL AND consumed_at < ?)", cutoff, cutoff).
Delete(&AuthFlow{}).Error
}
+109
View File
@@ -0,0 +1,109 @@
package model
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestAuthFlowIsBoundAndConsumedOnce(t *testing.T) {
truncateTables(t)
token, created, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 42,
SessionId: "session-a",
Payload: `{"affiliate_code":"invite"}`,
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
require.NotEmpty(t, token)
assert.NotEqual(t, token, created.TokenHash)
_, err = ConsumeAuthFlow(token, AuthFlowMatch{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 99,
SessionId: "session-a",
})
assert.ErrorIs(t, err, ErrAuthFlowInvalid)
peeked, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth, Provider: "github"})
require.NoError(t, err)
assert.Nil(t, peeked.ConsumedAt)
consumed, err := ConsumeAuthFlow(token, AuthFlowMatch{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 42,
SessionId: "session-a",
})
require.NoError(t, err)
require.NotNil(t, consumed.ConsumedAt)
_, err = ConsumeAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, ErrAuthFlowConsumed)
}
func TestAuthFlowExpiryIsEnforced(t *testing.T) {
truncateTables(t)
token, flow, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeTwoFALogin,
UserId: 7,
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
require.NoError(t, DB.Model(&AuthFlow{}).Where("id = ?", flow.Id).Update("expires_at", time.Now().Add(-time.Second)).Error)
_, err = GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTwoFALogin})
assert.True(t, errors.Is(err, ErrAuthFlowExpired))
_, err = ConsumeAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTwoFALogin})
assert.True(t, errors.Is(err, ErrAuthFlowExpired))
}
func TestExternalAuthAssertionCanOnlyBeClaimedOnce(t *testing.T) {
truncateTables(t)
expiresAt := time.Now().Add(time.Minute)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt))
err := ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt)
assert.ErrorIs(t, err, ErrAuthFlowConsumed)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "different-assertion", expiresAt))
}
func TestConsumeAuthFlowWithActionRollsBackTogether(t *testing.T) {
truncateTables(t)
token, _, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeTelegramBind,
UserId: 42,
SessionId: "session-a",
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
actionErr := errors.New("binding failed")
_, err = ConsumeAuthFlowWithAction(token, AuthFlowMatch{
Purpose: AuthFlowPurposeTelegramBind, UserId: 42, SessionId: "session-a",
}, func(tx *gorm.DB, _ *AuthFlow) error {
if err := ClaimExternalAuthAssertionWithTx(tx, AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute)); err != nil {
return err
}
return actionErr
})
assert.ErrorIs(t, err, actionErr)
flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute)))
}
+1
View File
@@ -27,3 +27,4 @@ var ErrRedeemFailed = errors.New("redeem.failed")
// 2FA errors
var ErrTwoFANotEnabled = errors.New("2fa not enabled")
var ErrTwoFAAlreadyEnabled = errors.New("2fa already enabled")
+103
View File
@@ -0,0 +1,103 @@
package model
import (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const ExternalIdentityProviderTelegram = "telegram"
var ErrExternalIdentityAlreadyClaimed = errors.New("external identity is already claimed")
// ExternalIdentityClaim is the durable ownership record for an identity issued
// by an external provider. The two unique indexes make both the provider
// subject and the user's provider slot single-owner without relying on a
// check-then-update sequence.
type ExternalIdentityClaim struct {
Id int64 `json:"id" gorm:"primaryKey"`
Provider string `json:"provider" gorm:"type:varchar(32);not null;uniqueIndex:idx_external_identity_subject,priority:1;uniqueIndex:idx_external_identity_user,priority:1"`
Subject string `json:"subject" gorm:"type:varchar(128);not null;uniqueIndex:idx_external_identity_subject,priority:2"`
UserId int `json:"user_id" gorm:"not null;index;uniqueIndex:idx_external_identity_user,priority:2"`
CreatedAt time.Time `json:"created_at"`
}
func (ExternalIdentityClaim) TableName() string {
return "external_identity_claims"
}
// ClaimExternalIdentityWithTx atomically claims a provider subject for one
// user. Repeating the exact mapping is idempotent; every competing subject or
// user is rejected. Ownership is read back instead of trusting RowsAffected,
// whose duplicate-key semantics differ between supported databases.
func ClaimExternalIdentityWithTx(tx *gorm.DB, provider, subject string, userId int) error {
provider = strings.TrimSpace(provider)
subject = strings.TrimSpace(subject)
if tx == nil || provider == "" || subject == "" || userId == 0 {
return errors.New("external identity claim is invalid")
}
claim := ExternalIdentityClaim{Provider: provider, Subject: subject, UserId: userId}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&claim)
if result.Error != nil {
return result.Error
}
var subjectOwner ExternalIdentityClaim
if err := tx.Where("provider = ? AND subject = ?", provider, subject).First(&subjectOwner).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrExternalIdentityAlreadyClaimed
}
return err
}
if subjectOwner.UserId != userId {
return ErrExternalIdentityAlreadyClaimed
}
var userClaim ExternalIdentityClaim
if err := tx.Where("provider = ? AND user_id = ?", provider, userId).First(&userClaim).Error; err != nil {
return err
}
if userClaim.Subject != subject {
return ErrExternalIdentityAlreadyClaimed
}
return nil
}
func ReleaseExternalIdentityWithTx(tx *gorm.DB, provider string, userId int) error {
provider = strings.TrimSpace(provider)
if tx == nil || provider == "" || userId == 0 {
return errors.New("external identity release is invalid")
}
return tx.Where("provider = ? AND user_id = ?", provider, userId).
Delete(&ExternalIdentityClaim{}).Error
}
func releaseAllExternalIdentitiesWithTx(tx *gorm.DB, userId int) error {
if tx == nil || userId == 0 {
return errors.New("external identity release is invalid")
}
return tx.Where("user_id = ?", userId).Delete(&ExternalIdentityClaim{}).Error
}
// InitializeExternalIdentityClaims imports legacy Telegram bindings after the
// claim table is migrated. Existing duplicate ownership fails migration rather
// than preserving an ambiguous login identity.
func InitializeExternalIdentityClaims() error {
var users []User
if err := DB.Unscoped().Select("id", "telegram_id").
Where("telegram_id <> ?", "").Find(&users).Error; err != nil {
return err
}
return DB.Transaction(func(tx *gorm.DB) error {
for _, user := range users {
if err := ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id); err != nil {
return fmt.Errorf("backfill Telegram identity for user %d: %w", user.Id, err)
}
}
return nil
})
}
+91
View File
@@ -0,0 +1,91 @@
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestExternalIdentityClaimEnforcesSingleOwnerAtomically(t *testing.T) {
truncateTables(t)
first := User{Username: "telegram-owner-one", Password: "password", AffCode: "telegram-owner-one"}
second := User{Username: "telegram-owner-two", Password: "password", AffCode: "telegram-owner-two"}
require.NoError(t, DB.Create(&first).Error)
require.NoError(t, DB.Create(&second).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", first.Id)
}))
err := DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", second.Id)
})
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
err = DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-456", first.Id)
})
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
var claims []ExternalIdentityClaim
require.NoError(t, DB.Find(&claims).Error)
require.Len(t, claims, 1)
assert.Equal(t, first.Id, claims[0].UserId)
assert.Equal(t, "telegram-123", claims[0].Subject)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ReleaseExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, first.Id)
}))
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", second.Id)
}))
}
func TestClearTelegramBindingReleasesIdentityClaim(t *testing.T) {
truncateTables(t)
user := User{Username: "telegram-unbind", Password: "password", TelegramId: "telegram-unbind-id"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, user.ClearBinding(ExternalIdentityProviderTelegram))
assert.Empty(t, user.TelegramId)
var count int64
require.NoError(t, DB.Model(&ExternalIdentityClaim{}).Where("user_id = ?", user.Id).Count(&count).Error)
assert.Zero(t, count)
}
func TestInitializeExternalIdentityClaimsIsIdempotent(t *testing.T) {
truncateTables(t)
user := User{Username: "telegram-legacy", Password: "password", TelegramId: "telegram-legacy-id"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, InitializeExternalIdentityClaims())
require.NoError(t, InitializeExternalIdentityClaims())
var claim ExternalIdentityClaim
require.NoError(t, DB.Where("provider = ? AND subject = ?", ExternalIdentityProviderTelegram, user.TelegramId).
First(&claim).Error)
assert.Equal(t, user.Id, claim.UserId)
}
func TestInitializeExternalIdentityClaimsRejectsAmbiguousLegacyBindings(t *testing.T) {
truncateTables(t)
first := User{Username: "telegram-legacy-one", Password: "password", TelegramId: "duplicate-telegram-id", AffCode: "telegram-legacy-one"}
second := User{Username: "telegram-legacy-two", Password: "password", TelegramId: "duplicate-telegram-id", AffCode: "telegram-legacy-two"}
require.NoError(t, DB.Create(&first).Error)
require.NoError(t, DB.Create(&second).Error)
err := InitializeExternalIdentityClaims()
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
var count int64
require.NoError(t, DB.Model(&ExternalIdentityClaim{}).Count(&count).Error)
assert.Zero(t, count)
}
+239
View File
@@ -0,0 +1,239 @@
package model
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/console_setting"
"gorm.io/gorm"
)
const retiredThemeOptionKey = "theme.frontend"
type legacyOptionTransform func(string) (string, error)
// MigrateRetiredFrontendOptions normalizes options that belonged to the
// removed dashboard frontend. Each legacy console setting is migrated in its
// own transaction so one malformed value cannot block the other settings.
func MigrateRetiredFrontendOptions() error {
if DB == nil {
return errors.New("database is not initialized")
}
var migrationErrors []error
if err := normalizeRetiredThemeOption(); err != nil {
migrationErrors = append(migrationErrors, fmt.Errorf("normalize %s: %w", retiredThemeOptionKey, err))
}
migrations := []struct {
source string
target string
transform legacyOptionTransform
}{
{source: "ApiInfo", target: "console_setting.api_info", transform: transformLegacyAPIInfo},
{source: "Announcements", target: "console_setting.announcements", transform: transformLegacyAnnouncements},
{source: "FAQ", target: "console_setting.faq", transform: transformLegacyFAQ},
}
for _, migration := range migrations {
if err := migrateLegacyOption(migration.source, migration.target, migration.transform); err != nil {
migrationErrors = append(migrationErrors, err)
}
}
if err := migrateLegacyUptimeOptions(); err != nil {
migrationErrors = append(migrationErrors, err)
}
return errors.Join(migrationErrors...)
}
func normalizeRetiredThemeOption() error {
return DB.Transaction(func(tx *gorm.DB) error {
var option Option
err := tx.Where(&Option{Key: retiredThemeOptionKey}).First(&option).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&Option{Key: retiredThemeOptionKey, Value: "default"}).Error
}
if err != nil {
return err
}
if option.Value == "default" {
return nil
}
return tx.Model(&option).Update("value", "default").Error
})
}
func migrateLegacyOption(sourceKey, targetKey string, transform legacyOptionTransform) error {
return DB.Transaction(func(tx *gorm.DB) error {
var source Option
if err := tx.Where(&Option{Key: sourceKey}).First(&source).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("read legacy option %s: %w", sourceKey, err)
}
var target Option
err := tx.Where(&Option{Key: targetKey}).First(&target).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("read target option %s: %w", targetKey, err)
}
if err == nil {
return tx.Delete(&source).Error
}
value, transformErr := transform(source.Value)
if transformErr != nil {
common.SysError(fmt.Sprintf("legacy option %s was not migrated: %v", sourceKey, transformErr))
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
target = Option{Key: targetKey}
}
target.Value = value
if err := tx.Save(&target).Error; err != nil {
return fmt.Errorf("write target option %s: %w", targetKey, err)
}
if err := tx.Delete(&source).Error; err != nil {
return fmt.Errorf("delete legacy option %s: %w", sourceKey, err)
}
return nil
})
}
func transformLegacyAPIInfo(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
var items []map[string]any
if err := common.UnmarshalJsonStr(value, &items); err != nil {
return "", err
}
if len(items) > 50 {
items = items[:50]
}
encoded, err := common.Marshal(items)
if err != nil {
return "", err
}
result := string(encoded)
if err := console_setting.ValidateConsoleSettings(result, "ApiInfo"); err != nil {
return "", err
}
return result, nil
}
func transformLegacyAnnouncements(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
if err := console_setting.ValidateConsoleSettings(value, "Announcements"); err != nil {
return "", err
}
return value, nil
}
func transformLegacyFAQ(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
var legacyItems []map[string]any
if err := common.UnmarshalJsonStr(value, &legacyItems); err != nil {
return "", err
}
items := make([]map[string]any, 0, len(legacyItems))
for index, item := range legacyItems {
question, _ := item["question"].(string)
if strings.TrimSpace(question) == "" {
question, _ = item["title"].(string)
}
answer, _ := item["answer"].(string)
if strings.TrimSpace(answer) == "" {
answer, _ = item["content"].(string)
}
if strings.TrimSpace(question) == "" || strings.TrimSpace(answer) == "" {
return "", fmt.Errorf("FAQ entry %d is missing a question or answer", index)
}
items = append(items, map[string]any{"question": question, "answer": answer})
}
if len(items) > 50 {
items = items[:50]
}
encoded, err := common.Marshal(items)
if err != nil {
return "", err
}
result := string(encoded)
if err := console_setting.ValidateConsoleSettings(result, "FAQ"); err != nil {
return "", err
}
return result, nil
}
func migrateLegacyUptimeOptions() error {
return DB.Transaction(func(tx *gorm.DB) error {
var urlOption Option
urlErr := tx.Where(&Option{Key: "UptimeKumaUrl"}).First(&urlOption).Error
if urlErr != nil && !errors.Is(urlErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read legacy option UptimeKumaUrl: %w", urlErr)
}
var slugOption Option
slugErr := tx.Where(&Option{Key: "UptimeKumaSlug"}).First(&slugOption).Error
if slugErr != nil && !errors.Is(slugErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read legacy option UptimeKumaSlug: %w", slugErr)
}
if errors.Is(urlErr, gorm.ErrRecordNotFound) && errors.Is(slugErr, gorm.ErrRecordNotFound) {
return nil
}
var target Option
targetErr := tx.Where(&Option{Key: "console_setting.uptime_kuma_groups"}).First(&target).Error
if targetErr != nil && !errors.Is(targetErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read target option console_setting.uptime_kuma_groups: %w", targetErr)
}
if targetErr == nil {
if urlErr == nil {
if err := tx.Delete(&urlOption).Error; err != nil {
return err
}
}
if slugErr == nil {
return tx.Delete(&slugOption).Error
}
return nil
}
if urlErr != nil || slugErr != nil || strings.TrimSpace(urlOption.Value) == "" || strings.TrimSpace(slugOption.Value) == "" {
common.SysError("legacy Uptime Kuma options were not migrated: both URL and slug are required")
return nil
}
groups := []map[string]any{{
"id": 1,
"categoryName": "old",
"url": urlOption.Value,
"slug": slugOption.Value,
"description": "",
}}
encoded, err := common.Marshal(groups)
if err != nil {
return err
}
value := string(encoded)
if err := console_setting.ValidateConsoleSettings(value, "UptimeKumaGroups"); err != nil {
common.SysError(fmt.Sprintf("legacy Uptime Kuma options were not migrated: %v", err))
return nil
}
if errors.Is(targetErr, gorm.ErrRecordNotFound) {
target = Option{Key: "console_setting.uptime_kuma_groups"}
}
target.Value = value
if err := tx.Save(&target).Error; err != nil {
return fmt.Errorf("write target option console_setting.uptime_kuma_groups: %w", err)
}
if err := tx.Delete(&urlOption).Error; err != nil {
return err
}
return tx.Delete(&slugOption).Error
})
}
+180
View File
@@ -0,0 +1,180 @@
package model
import (
"fmt"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func useFrontendOptionMigrationDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB := DB
previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&Option{}))
DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
t.Cleanup(func() {
DB = previousDB
common.SetMainDatabaseType(previousType)
})
return db
}
func requireOptionValue(t *testing.T, db *gorm.DB, key string) string {
t.Helper()
var option Option
require.NoError(t, db.Where(&Option{Key: key}).First(&option).Error)
return option.Value
}
func requireOptionMissing(t *testing.T, db *gorm.DB, key string) {
t.Helper()
var option Option
assert.ErrorIs(t, db.Where(&Option{Key: key}).First(&option).Error, gorm.ErrRecordNotFound)
}
func TestMigrateRetiredFrontendOptionsMigratesValidValuesIdempotently(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacy := []Option{
{Key: retiredThemeOptionKey, Value: "classic"},
{Key: "ApiInfo", Value: `[{"url":"https://api.example.com","route":"primary","description":"API","color":"blue"}]`},
{Key: "Announcements", Value: `[{"content":"maintenance","publishDate":"2026-07-20T00:00:00Z","type":"warning"}]`},
{Key: "FAQ", Value: `[{"title":"Question","content":"Answer"}]`},
{Key: "UptimeKumaUrl", Value: "https://status.example.com"},
{Key: "UptimeKumaSlug", Value: "status"},
}
require.NoError(t, db.Create(&legacy).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, "default", requireOptionValue(t, db, retiredThemeOptionKey))
assert.JSONEq(t, legacy[1].Value, requireOptionValue(t, db, "console_setting.api_info"))
assert.Equal(t, legacy[2].Value, requireOptionValue(t, db, "console_setting.announcements"))
assert.JSONEq(t, `[{"question":"Question","answer":"Answer"}]`, requireOptionValue(t, db, "console_setting.faq"))
assert.JSONEq(t, `[{
"id":1,"categoryName":"old","url":"https://status.example.com","slug":"status","description":""
}]`, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
before, err := AllOption()
require.NoError(t, err)
require.NoError(t, MigrateRetiredFrontendOptions())
after, err := AllOption()
require.NoError(t, err)
assert.ElementsMatch(t, before, after)
}
func TestLegacyConsoleListMigrationCapsAPIInfoAndFAQ(t *testing.T) {
apiInfo := make([]map[string]any, 51)
faq := make([]map[string]any, 51)
for i := range apiInfo {
apiInfo[i] = map[string]any{
"url": fmt.Sprintf("https://api-%d.example.com", i),
"route": fmt.Sprintf("route-%d", i),
"description": "API",
"color": "blue",
}
faq[i] = map[string]any{"title": fmt.Sprintf("Question %d", i), "content": "Answer"}
}
apiBytes, err := common.Marshal(apiInfo)
require.NoError(t, err)
faqBytes, err := common.Marshal(faq)
require.NoError(t, err)
migratedAPI, err := transformLegacyAPIInfo(string(apiBytes))
require.NoError(t, err)
migratedFAQ, err := transformLegacyFAQ(string(faqBytes))
require.NoError(t, err)
var apiResult []map[string]any
require.NoError(t, common.UnmarshalJsonStr(migratedAPI, &apiResult))
var faqResult []map[string]any
require.NoError(t, common.UnmarshalJsonStr(migratedFAQ, &faqResult))
assert.Len(t, apiResult, 50)
assert.Len(t, faqResult, 50)
}
func TestMigrateRetiredFrontendOptionsPreservesMalformedValuesAndContinues(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacy := []Option{
{Key: "ApiInfo", Value: `{invalid`},
{Key: "FAQ", Value: `[{"question":"Question","answer":"Answer"}]`},
{Key: "UptimeKumaUrl", Value: "https://status.example.com"},
}
require.NoError(t, db.Create(&legacy).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, `{invalid`, requireOptionValue(t, db, "ApiInfo"))
requireOptionMissing(t, db, "console_setting.api_info")
requireOptionMissing(t, db, "FAQ")
assert.JSONEq(t, legacy[1].Value, requireOptionValue(t, db, "console_setting.faq"))
assert.Equal(t, "https://status.example.com", requireOptionValue(t, db, "UptimeKumaUrl"))
requireOptionMissing(t, db, "console_setting.uptime_kuma_groups")
}
func TestMigrateRetiredFrontendOptionsPreservesMixedInvalidFAQ(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacyFAQ := `[{"question":"Valid question","answer":"Valid answer"},{"question":"Missing answer"}]`
require.NoError(t, db.Create(&Option{Key: "FAQ", Value: legacyFAQ}).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, legacyFAQ, requireOptionValue(t, db, "FAQ"))
requireOptionMissing(t, db, "console_setting.faq")
}
func TestMigrateRetiredFrontendOptionsKeepsAuthoritativeTargets(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
options := []Option{
{Key: "ApiInfo", Value: `{invalid`},
{Key: "console_setting.api_info", Value: `[{"url":"https://new.example.com"}]`},
{Key: "UptimeKumaUrl", Value: "https://old.example.com"},
{Key: "UptimeKumaSlug", Value: "old"},
{Key: "console_setting.uptime_kuma_groups", Value: `[{"url":"https://new.example.com"}]`},
}
require.NoError(t, db.Create(&options).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, options[1].Value, requireOptionValue(t, db, "console_setting.api_info"))
assert.Equal(t, options[4].Value, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
}
func TestMigrateRetiredFrontendOptionsKeepsEmptyAuthoritativeTargets(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
options := []Option{
{Key: "ApiInfo", Value: `[{"url":"https://old.example.com"}]`},
{Key: "console_setting.api_info", Value: ""},
{Key: "UptimeKumaUrl", Value: "https://old.example.com"},
{Key: "UptimeKumaSlug", Value: "old"},
{Key: "console_setting.uptime_kuma_groups", Value: ""},
}
require.NoError(t, db.Create(&options).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Empty(t, requireOptionValue(t, db, "console_setting.api_info"))
assert.Empty(t, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
}
func TestRetiredThemeOptionIsPersistedButNotPublished(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
previousMap := common.OptionMap
t.Cleanup(func() { common.OptionMap = previousMap })
common.OptionMap = map[string]string{}
require.NoError(t, UpdateOption(retiredThemeOptionKey, "default"))
assert.Equal(t, "default", requireOptionValue(t, db, retiredThemeOptionKey))
_, published := common.OptionMap[retiredThemeOptionKey]
assert.False(t, published)
}
+2 -29
View File
@@ -198,7 +198,7 @@ func buildOpField(action string, params map[string]interface{}) map[string]inter
// RecordLoginLog 记录用户登录成功的审计日志(type=LogTypeLogin)。
// username 由调用方传入(登录流程已持有用户对象),避免额外的数据库查询。
// content 为英文兜底文本(用于导出/经典前端);action+params 供前端本地化渲染。
// content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。
// extra 可携带 login_method、user_agent 等附加信息(普通用户可见)。
func RecordLoginLog(userId int, username string, content string, ip string, action string, params map[string]interface{}, extra map[string]interface{}) {
other := map[string]interface{}{}
@@ -222,7 +222,7 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti
// RecordOperationAuditLog 记录管理/高危操作审计日志(type=LogTypeManage)。
// logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入
// action params。username 内部按 logUserId 查询。content 为英文兜底文本(导出/经典前端用)。
// action params。username 内部按 logUserId 查询。content 为英文兜底文本(导出使用)。
// action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。
// adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离);
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
@@ -735,30 +735,3 @@ func DeleteOldLogBatch(ctx context.Context, targetTimestamp int64, limit int) (i
}
return result.RowsAffected, nil
}
func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, error) {
if limit <= 0 {
limit = 100
}
var total int64 = 0
for {
if nil != ctx.Err() {
return total, ctx.Err()
}
rowsAffected, err := DeleteOldLogBatch(ctx, targetTimestamp, limit)
if nil != err {
return total, err
}
total += rowsAffected
if rowsAffected < int64(limit) {
break
}
}
return total, nil
}
+18
View File
@@ -272,6 +272,9 @@ func migrateDB() error {
&Channel{},
&Token{},
&User{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
&PasskeyCredential{},
&Option{},
&Redemption{},
@@ -303,6 +306,12 @@ func migrateDB() error {
if err != nil {
return err
}
if err := InitializeUserAuthVersions(); err != nil {
return err
}
if err := InitializeExternalIdentityClaims(); err != nil {
return err
}
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
@@ -326,6 +335,9 @@ func migrateDBFast() error {
{&Channel{}, "Channel"},
{&Token{}, "Token"},
{&User{}, "User"},
{&UserSession{}, "UserSession"},
{&AuthFlow{}, "AuthFlow"},
{&ExternalIdentityClaim{}, "ExternalIdentityClaim"},
{&PasskeyCredential{}, "PasskeyCredential"},
{&Option{}, "Option"},
{&Redemption{}, "Redemption"},
@@ -375,6 +387,12 @@ func migrateDBFast() error {
return err
}
}
if err := InitializeUserAuthVersions(); err != nil {
return err
}
if err := InitializeExternalIdentityClaims(); err != nil {
return err
}
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
+6 -2
View File
@@ -254,6 +254,12 @@ func UpdateOptionsBulk(values map[string]string) error {
}
func updateOptionMap(key string, value string) (err error) {
if key == retiredThemeOptionKey {
common.OptionMapRWMutex.Lock()
delete(common.OptionMap, key)
common.OptionMapRWMutex.Unlock()
return nil
}
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
common.OptionMap[key] = value
@@ -606,8 +612,6 @@ func handleConfigUpdate(key, value string) bool {
} else if configName == "billing_setting" {
InvalidatePricingCache()
ratio_setting.InvalidateExposedDataCache()
} else if configName == "theme" {
system_setting.UpdateAndSyncTheme()
}
return true // 已处理
+81 -46
View File
@@ -2,7 +2,6 @@ package model
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -46,7 +45,7 @@ func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport {
return nil
}
var transports []string
if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil {
if err := common.Unmarshal([]byte(p.Transports), &transports); err != nil {
return nil
}
result := make([]protocol.AuthenticatorTransport, 0, len(transports))
@@ -65,7 +64,7 @@ func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport
for i, transport := range list {
stringList[i] = string(transport)
}
encoded, err := json.Marshal(stringList)
encoded, err := common.Marshal(stringList)
if err != nil {
return
}
@@ -121,24 +120,6 @@ func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credentia
return passkey
}
func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) {
if credential == nil || p == nil {
return
}
p.CredentialID = base64.StdEncoding.EncodeToString(credential.ID)
p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey)
p.AttestationType = credential.AttestationType
p.AAGUID = base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID)
p.SignCount = credential.Authenticator.SignCount
p.CloneWarning = credential.Authenticator.CloneWarning
p.UserPresent = credential.Flags.UserPresent
p.UserVerified = credential.Flags.UserVerified
p.BackupEligible = credential.Flags.BackupEligible
p.BackupState = credential.Flags.BackupState
p.Attachment = string(credential.Authenticator.Attachment)
p.SetTransports(credential.Transport)
}
func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) {
if userID == 0 {
common.SysLog("GetPasskeyByUserID: empty user ID")
@@ -177,34 +158,88 @@ func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) {
return &credential, nil
}
func UpsertPasskeyCredential(credential *PasskeyCredential) error {
if credential == nil {
common.SysLog("UpsertPasskeyCredential: nil credential provided")
// UpdatePasskeyAssertionState persists only fields produced by a successful
// assertion. Registration identity (credential ID, public key, AAGUID,
// transports and attestation metadata) is immutable on this path.
func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time) error {
if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() {
return fmt.Errorf("Passkey 保存失败,请重试")
}
return DB.Transaction(func(tx *gorm.DB) error {
// 使用Unscoped()进行硬删除,避免唯一索引冲突
if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := tx.Create(credential).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
return nil
})
}
func DeletePasskeyByUserID(userID int) error {
if userID == 0 {
common.SysLog("DeletePasskeyByUserID: empty user ID")
return fmt.Errorf("删除失败,请重试")
credentialID := base64.StdEncoding.EncodeToString(credential.ID)
result := DB.Model(&PasskeyCredential{}).
Where("user_id = ? AND credential_id = ?", userID, credentialID).
Updates(map[string]interface{}{
"sign_count": credential.Authenticator.SignCount,
"clone_warning": credential.Authenticator.CloneWarning,
"user_present": credential.Flags.UserPresent,
"user_verified": credential.Flags.UserVerified,
"backup_eligible": credential.Flags.BackupEligible,
"backup_state": credential.Flags.BackupState,
"last_used_at": lastUsedAt,
})
if result.Error != nil {
return result.Error
}
// 使用Unscoped()进行硬删除,避免唯一索引冲突
if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err))
return fmt.Errorf("删除失败,请重试")
if result.RowsAffected != 1 {
return ErrPasskeyNotFound
}
return nil
}
func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) error {
if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := tx.Create(credential).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
return nil
}
// UpsertPasskeyCredentialWithAuthVersion is reserved for enrollment changes;
// assertion sign-count updates must use UpdatePasskeyAssertionState.
func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error {
if credential == nil || credential.UserID <= 0 {
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
if _, err := IncrementUserAuthVersionWithTx(tx, credential.UserID); err != nil {
return err
}
return upsertPasskeyCredentialWithTx(tx, credential)
}); err != nil {
return err
}
return PublishUserAuthCache(credential.UserID)
}
func DeletePasskeyByUserIDWithAuthVersion(userID int) error {
if userID == 0 {
return fmt.Errorf("删除失败,请重试")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
var credential PasskeyCredential
if err := lockForUpdate(tx).Where("user_id = ?", userID).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPasskeyNotFound
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userID); err != nil {
return err
}
result := tx.Unscoped().Delete(&credential)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrPasskeyNotFound
}
return nil
}); err != nil {
return err
}
return PublishUserAuthCache(userID)
}
+28 -13
View File
@@ -431,7 +431,7 @@ func getUserGroupByIdTx(tx *gorm.DB, userId int) (string, error) {
tx = DB
}
var group string
if err := tx.Model(&User{}).Where("id = ?", userId).Select(commonGroupCol).Find(&group).Error; err != nil {
if err := lockForUpdate(tx).Model(&User{}).Where("id = ?", userId).Select(commonGroupCol).Find(&group).Error; err != nil {
return "", err
}
return group, nil
@@ -557,6 +557,12 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
return sub, nil
}
func refreshSubscriptionUserGroupCache(userId int, operation string) {
if err := RefreshUserGroupCache(userId); err != nil {
common.SysError(fmt.Sprintf("failed to refresh user group cache after %s for user %d: %v", operation, userId, err))
}
}
// Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan.
// expectedPaymentProvider guards against cross-gateway callback attacks (empty skips the check).
// actualPaymentMethod updates the order's PaymentMethod to reflect the real payment type used (empty skips update).
@@ -594,11 +600,13 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP
if !plan.Enabled {
// still allow completion for already purchased orders
}
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
_, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order")
subscription, err := CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order")
if err != nil {
return err
}
if subscription.PrevUserGroup != "" {
upgradeGroup = strings.TrimSpace(subscription.UpgradeGroup)
}
if err := upsertSubscriptionTopUpTx(tx, &order); err != nil {
return err
}
@@ -623,7 +631,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP
return err
}
if upgradeGroup != "" && logUserId > 0 {
_ = UpdateUserGroupCache(logUserId, upgradeGroup)
refreshSubscriptionUserGroupCache(logUserId, "subscription payment completion")
}
if logUserId > 0 {
msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod)
@@ -702,15 +710,19 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e
if err != nil {
return "", err
}
groupChanged := false
err = DB.Transaction(func(tx *gorm.DB) error {
_, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin")
subscription, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin")
if err == nil {
groupChanged = subscription.PrevUserGroup != ""
}
return err
})
if err != nil {
return "", err
}
if strings.TrimSpace(plan.UpgradeGroup) != "" {
_ = UpdateUserGroupCache(userId, plan.UpgradeGroup)
if groupChanged {
refreshSubscriptionUserGroupCache(userId, "admin subscription creation")
return fmt.Sprintf("用户分组将升级到 %s", plan.UpgradeGroup), nil
}
return "", nil
@@ -774,7 +786,8 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
}
}
if _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance); err != nil {
subscription, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance)
if err != nil {
return err
}
@@ -799,7 +812,9 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
logPlanTitle = plan.Title
logMoney = plan.PriceAmount
chargedQuota = requiredQuota
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
if subscription.PrevUserGroup != "" {
upgradeGroup = strings.TrimSpace(subscription.UpgradeGroup)
}
return nil
})
if err != nil {
@@ -812,7 +827,7 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
}
}
if upgradeGroup != "" {
_ = UpdateUserGroupCache(userId, upgradeGroup)
refreshSubscriptionUserGroupCache(userId, "subscription balance purchase")
}
msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota)
RecordLog(userId, LogTypeTopup, msg)
@@ -935,7 +950,7 @@ func AdminInvalidateUserSubscription(userSubscriptionId int) (string, error) {
return "", err
}
if cacheGroup != "" && userId > 0 {
_ = UpdateUserGroupCache(userId, cacheGroup)
refreshSubscriptionUserGroupCache(userId, "admin subscription update")
}
if downgradeGroup != "" {
return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil
@@ -976,7 +991,7 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) {
return "", err
}
if cacheGroup != "" && userId > 0 {
_ = UpdateUserGroupCache(userId, cacheGroup)
refreshSubscriptionUserGroupCache(userId, "admin subscription deletion")
}
if downgradeGroup != "" {
return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil
@@ -1203,7 +1218,7 @@ func ExpireDueSubscriptions(limit int) (int, error) {
return expiredCount, err
}
if cacheGroup != "" {
_ = UpdateUserGroupCache(userId, cacheGroup)
refreshSubscriptionUserGroupCache(userId, "subscription expiration")
}
}
return expiredCount, nil
+150
View File
@@ -0,0 +1,150 @@
package model
import (
"context"
"errors"
"fmt"
"net"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestSubscriptionGroupTransitionsPreserveAuthVersionAndSessions(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
now := time.Now().Unix()
user := User{
Username: "subscription-auth-user",
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, CreateUserSession(&UserSession{
SID: "subscription-auth-session",
UserID: user.Id,
Version: 1,
UserAuthVersion: 1,
Status: UserSessionStatusActive,
RefreshHash: "refresh-hash",
LoginMethod: "password",
LastActiveAt: now,
ExpiresAt: now + 3600,
}))
require.NoError(t, populateUserCache(user))
plan := &SubscriptionPlan{
Title: "Upgraded",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 100,
UpgradeGroup: "pro",
Enabled: true,
}
require.NoError(t, DB.Create(plan).Error)
subscription, err := CreateUserSubscriptionFromPlanTx(DB, user.Id, plan, "test")
require.NoError(t, err)
require.Equal(t, "default", subscription.PrevUserGroup)
require.NoError(t, RefreshUserGroupCache(user.Id))
var updated User
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "pro", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
var session UserSession
require.NoError(t, DB.First(&session, "sid = ?", "subscription-auth-session").Error)
assert.Equal(t, UserSessionStatusActive, session.Status)
cached, err := GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
target, err := downgradeUserGroupForSubscriptionTx(tx, subscription, now+1)
assert.Equal(t, "default", target)
return err
}))
require.NoError(t, RefreshUserGroupCache(user.Id))
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "default", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
require.NoError(t, DB.First(&session, "sid = ?", "subscription-auth-session").Error)
assert.Equal(t, UserSessionStatusActive, session.Status)
cached, err = GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, "default", cached.Group)
}
func TestSubscriptionGroupCacheRefreshFailureDoesNotChangeCommittedResult(t *testing.T) {
previousDB, previousLogDB := DB, LOG_DB
previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType()
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
DB, LOG_DB = db, db
require.NoError(t, db.AutoMigrate(&User{}, &SubscriptionPlan{}, &UserSubscription{}))
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(4)
t.Cleanup(func() {
DB, LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
_ = sqlDB.Close()
})
user := User{
Username: "subscription-cache-failure",
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
plan := &SubscriptionPlan{
Title: "Cache failure plan",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 100,
UpgradeGroup: "pro",
Enabled: true,
}
require.NoError(t, DB.Create(plan).Error)
InvalidateSubscriptionPlanCache(plan.Id)
oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB
common.RedisEnabled = true
common.RDB = redis.NewClient(&redis.Options{
Dialer: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("forced redis failure")
},
MaxRetries: -1,
})
t.Cleanup(func() {
_ = common.RDB.Close()
common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB
})
message, err := AdminBindSubscription(user.Id, plan.Id, "test")
require.NoError(t, err)
assert.Contains(t, message, "pro")
var updated User
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "pro", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
var subscription UserSubscription
require.NoError(t, DB.Where("user_id = ?", user.Id).First(&subscription).Error)
assert.Equal(t, "active", subscription.Status)
}
+6
View File
@@ -37,6 +37,9 @@ func TestMain(m *testing.M) {
if err := db.AutoMigrate(
&Task{},
&User{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
&Token{},
&PasskeyCredential{},
&TwoFA{},
@@ -65,6 +68,9 @@ func truncateTables(t *testing.T) {
t.Helper()
t.Cleanup(func() {
DB.Exec("DELETE FROM tasks")
DB.Exec("DELETE FROM auth_flows")
DB.Exec("DELETE FROM external_identity_claims")
DB.Exec("DELETE FROM user_sessions")
DB.Exec("DELETE FROM passkey_credentials")
DB.Exec("DELETE FROM two_fa_backup_codes")
DB.Exec("DELETE FROM two_fas")
+119 -53
View File
@@ -62,8 +62,12 @@ func IsTwoFAEnabled(userId int) (bool, error) {
return twoFA != nil && twoFA.IsEnabled, nil
}
// CreateTwoFA 创建2FA设置
func (t *TwoFA) Create() error {
// CreatePendingTwoFASetup stores a disabled factor while the user completes
// enrollment. Enabling a factor must use EnableWithAuthVersion.
func (t *TwoFA) CreatePendingTwoFASetup() error {
if t == nil || t.UserId <= 0 || t.IsEnabled {
return errors.New("无效的2FA待验证设置")
}
// 检查用户是否已存在2FA设置
existing, err := GetTwoFAByUserId(t.UserId)
if err != nil {
@@ -85,29 +89,35 @@ func (t *TwoFA) Create() error {
return DB.Create(t).Error
}
// Update 更新2FA设置
func (t *TwoFA) Update() error {
func (t *TwoFA) updateUsageState() error {
if t.Id == 0 {
return errors.New("2FA记录ID不能为空")
}
return DB.Save(t).Error
return DB.Model(&TwoFA{}).Where("id = ?", t.Id).Updates(map[string]interface{}{
"failed_attempts": t.FailedAttempts,
"locked_until": t.LockedUntil,
"last_used_at": t.LastUsedAt,
}).Error
}
// Delete 删除2FA设置
func (t *TwoFA) Delete() error {
if t.Id == 0 {
// DeletePendingTwoFASetup removes only an unverified setup. Enabled factors
// must use DisableTwoFAWithAuthVersion.
func (t *TwoFA) DeletePendingTwoFASetup() error {
if t == nil || t.Id == 0 || t.UserId <= 0 {
return errors.New("2FA记录ID不能为空")
}
// 使用事务确保原子性
return DB.Transaction(func(tx *gorm.DB) error {
// 同时删除相关的备用码记录(硬删除)
var pending TwoFA
if err := lockForUpdate(tx).
Where("id = ? AND user_id = ? AND is_enabled = ?", t.Id, t.UserId, false).
First(&pending).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
// 硬删除2FA记录
return tx.Unscoped().Delete(t).Error
return tx.Unscoped().Delete(&pending).Error
})
}
@@ -115,7 +125,7 @@ func (t *TwoFA) Delete() error {
func (t *TwoFA) ResetFailedAttempts() error {
t.FailedAttempts = 0
t.LockedUntil = nil
return t.Update()
return t.updateUsageState()
}
// IncrementFailedAttempts 增加失败尝试次数
@@ -174,36 +184,55 @@ func (t *TwoFA) IsLocked() bool {
return time.Now().Before(*t.LockedUntil)
}
// CreateBackupCodes 创建备用码
func CreateBackupCodes(userId int, codes []string) error {
// CreatePendingTwoFASetupBackupCodes stores recovery codes for an unverified
// setup. Regeneration for an enabled factor must advance auth_version.
func CreatePendingTwoFASetupBackupCodes(userId int, codes []string) error {
return DB.Transaction(func(tx *gorm.DB) error {
// 先删除现有的备用码
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
var pending TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, false).First(&pending).Error; err != nil {
return err
}
// 创建新的备用码记录
for _, code := range codes {
hashedCode, err := common.HashBackupCode(code)
if err != nil {
return err
}
backupCode := TwoFABackupCode{
UserId: userId,
CodeHash: hashedCode,
IsUsed: false,
}
if err := tx.Create(&backupCode).Error; err != nil {
return err
}
}
return nil
return replaceBackupCodesWithTx(tx, userId, codes)
})
}
func replaceBackupCodesWithTx(tx *gorm.DB, userId int, codes []string) error {
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
for _, code := range codes {
hashedCode, err := common.HashBackupCode(code)
if err != nil {
return err
}
if err := tx.Create(&TwoFABackupCode{UserId: userId, CodeHash: hashedCode, IsUsed: false}).Error; err != nil {
return err
}
}
return nil
}
// ReplaceBackupCodesWithAuthVersion atomically replaces the factor's recovery
// credentials and advances the user's authentication version.
func ReplaceBackupCodesWithAuthVersion(userId int, codes []string) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
var enabled TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&enabled).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFANotEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userId); err != nil {
return err
}
return replaceBackupCodesWithTx(tx, userId, codes)
}); err != nil {
return err
}
return PublishUserAuthCache(userId)
}
// ValidateBackupCode 验证并使用备用码
func ValidateBackupCode(userId int, code string) (bool, error) {
if !common.ValidateBackupCode(code) {
@@ -245,26 +274,63 @@ func GetUnusedBackupCodeCount(userId int) (int, error) {
return int(count), err
}
// DisableTwoFA 禁用用户的2FA
func DisableTwoFA(userId int) error {
twoFA, err := GetTwoFAByUserId(userId)
if err != nil {
// DisableTwoFAWithAuthVersion atomically removes the factor and invalidates
// every access token issued against the previous security configuration.
func DisableTwoFAWithAuthVersion(userId int) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
var twoFA TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&twoFA).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFANotEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userId); err != nil {
return err
}
if err := tx.Unscoped().Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&twoFA).Error
}); err != nil {
return err
}
if twoFA == nil {
return ErrTwoFANotEnabled
}
// 删除2FA设置和备用码
return twoFA.Delete()
return PublishUserAuthCache(userId)
}
// EnableTwoFA 启用2FA
func (t *TwoFA) Enable() error {
// EnableWithAuthVersion atomically enables this factor and advances the user
// authentication version so pre-enrollment sessions cannot remain valid.
func (t *TwoFA) EnableWithAuthVersion() error {
if t == nil || t.Id == 0 || t.UserId == 0 {
return errors.New("2FA记录ID不能为空")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
var pending TwoFA
if err := lockForUpdate(tx).Where("id = ? AND user_id = ? AND is_enabled = ?", t.Id, t.UserId, false).First(&pending).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFAAlreadyEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, t.UserId); err != nil {
return err
}
result := tx.Model(&pending).
Updates(map[string]interface{}{"is_enabled": true, "failed_attempts": 0, "locked_until": nil})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrTwoFAAlreadyEnabled
}
return nil
}); err != nil {
return err
}
t.IsEnabled = true
t.FailedAttempts = 0
t.LockedUntil = nil
return t.Update()
return PublishUserAuthCache(t.UserId)
}
// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录
@@ -289,7 +355,7 @@ func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
t.LockedUntil = nil
t.LastUsedAt = &now
if err := t.Update(); err != nil {
if err := t.updateUsageState(); err != nil {
common.SysLog("更新2FA使用记录失败: " + err.Error())
}
@@ -323,7 +389,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
t.LockedUntil = nil
t.LastUsedAt = &now
if err := t.Update(); err != nil {
if err := t.updateUsageState(); err != nil {
common.SysLog("更新2FA使用记录失败: " + err.Error())
}
+113 -18
View File
@@ -108,18 +108,22 @@ type User struct {
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"`
AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"`
}
func (user *User) ToBaseUser() *UserBase {
cache := &UserBase{
Id: user.Id,
Group: user.Group,
Quota: user.Quota,
Status: user.Status,
Username: user.Username,
Setting: user.Setting,
Email: user.Email,
Id: user.Id,
Group: user.Group,
Quota: user.Quota,
Status: user.Status,
Role: user.Role,
Username: user.Username,
Setting: user.Setting,
Email: user.Email,
AuthVersion: user.AuthVersion,
CacheSchema: userCacheSchemaVersion,
}
return cache
}
@@ -699,10 +703,23 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
}
func (user *User) Update(updatePassword bool) error {
if err := user.UpdateWithTx(DB, updatePassword); err != nil {
var previousAuthVersion int64
if err := DB.Model(&User{}).Where("id = ?", user.Id).Select("auth_version").Find(&previousAuthVersion).Error; err != nil {
return err
}
return updateUserCache(*user)
if err := DB.Transaction(func(tx *gorm.DB) error {
return user.UpdateWithTx(tx, updatePassword)
}); err != nil {
return err
}
if err := updateUserCache(*user); err != nil {
return err
}
if user.AuthVersion > previousAuthVersion {
_, err := RevokeAllUserSessions(user.Id, "user_security_changed")
return err
}
return nil
}
func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
@@ -718,17 +735,43 @@ func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
if err = tx.First(&current, user.Id).Error; err != nil {
return err
}
if err = tx.Model(&current).Omit("quota", "used_quota", "request_count").Updates(newUser).Error; err != nil {
// Updates(struct) ignores zero values. Match that behavior when deciding
// whether this request actually changes authentication-sensitive state;
// partial self-profile updates intentionally leave role/status/group empty.
authChanged := (updatePassword && current.Password != newUser.Password) ||
(newUser.Role != 0 && current.Role != newUser.Role) ||
(newUser.Status != 0 && current.Status != newUser.Status) ||
(newUser.Group != "" && current.Group != newUser.Group)
if authChanged {
newUser.AuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
return err
}
}
if err = tx.Model(&current).Omit("quota", "used_quota", "request_count", "auth_version").Updates(newUser).Error; err != nil {
return err
}
return tx.First(user, user.Id).Error
}
func (user *User) Edit(updatePassword bool) error {
if err := user.EditWithTx(DB, updatePassword); err != nil {
var previousAuthVersion int64
if err := DB.Model(&User{}).Where("id = ?", user.Id).Select("auth_version").Find(&previousAuthVersion).Error; err != nil {
return err
}
return updateUserCache(*user)
if err := DB.Transaction(func(tx *gorm.DB) error {
return user.EditWithTx(tx, updatePassword)
}); err != nil {
return err
}
if err := updateUserCache(*user); err != nil {
return err
}
if user.AuthVersion > previousAuthVersion {
_, err := RevokeAllUserSessions(user.Id, "user_security_changed")
return err
}
return nil
}
func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
@@ -755,6 +798,13 @@ func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
if err = tx.First(&current, user.Id).Error; err != nil {
return err
}
authChanged := (updatePassword && current.Password != newUser.Password) || current.Group != newUser.Group
if authChanged {
newUser.AuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
return err
}
}
if err = tx.Model(&current).Updates(updates).Error; err != nil {
return err
}
@@ -781,7 +831,15 @@ func (user *User) ClearBinding(bindingType string) error {
return errors.New("invalid binding type")
}
if err := DB.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil {
if err := DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil {
return err
}
if bindingType == ExternalIdentityProviderTelegram {
return ReleaseExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.Id)
}
return nil
}); err != nil {
return err
}
@@ -796,11 +854,23 @@ func (user *User) Delete() error {
if user.Id == 0 {
return errors.New("id 为空!")
}
if err := DB.Delete(user).Error; err != nil {
var nextAuthVersion int64
if err := DB.Transaction(func(tx *gorm.DB) error {
var err error
nextAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
return err
}
return tx.Delete(user).Error
}); err != nil {
return err
}
if err := publishCommittedUserAuthVersion(user.Id, nextAuthVersion); err != nil {
return err
}
if _, err := RevokeAllUserSessions(user.Id, "user_deleted"); err != nil {
return err
}
// 清除缓存
return invalidateUserCache(user.Id)
}
@@ -809,7 +879,13 @@ func (user *User) HardDelete() error {
return errors.New("id 为空!")
}
var tokens []Token
var deletedAuthVersion int64
err := DB.Transaction(func(tx *gorm.DB) error {
var err error
deletedAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
return err
}
if common.RedisEnabled {
if err := tx.Unscoped().Select("id", commonKeyCol).Where("user_id = ?", user.Id).Find(&tokens).Error; err != nil {
return err
@@ -823,6 +899,9 @@ func (user *User) HardDelete() error {
if err != nil {
return err
}
if err := publishCommittedUserAuthVersion(user.Id, deletedAuthVersion); err != nil {
common.SysError(fmt.Sprintf("failed to publish auth tombstone after hard deleting user %d: %v", user.Id, err))
}
if err := invalidateTokensCache(tokens); err != nil {
common.SysError(fmt.Sprintf("failed to invalidate token cache after hard deleting user %d: %v", user.Id, err))
}
@@ -833,9 +912,14 @@ func (user *User) HardDelete() error {
}
func deleteUserAuthenticationData(tx *gorm.DB, userId int) error {
if err := releaseAllExternalIdentitiesWithTx(tx, userId); err != nil {
return err
}
for _, authenticationData := range []any{
&TwoFABackupCode{},
&TwoFA{},
&UserSession{},
&AuthFlow{},
&PasskeyCredential{},
&Token{},
} {
@@ -997,7 +1081,18 @@ func ResetUserPasswordByEmail(email string, password string) error {
if err != nil {
return err
}
err = DB.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error
if err = DB.Transaction(func(tx *gorm.DB) error {
if _, err := IncrementUserAuthVersionWithTx(tx, user.Id); err != nil {
return err
}
return tx.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error
}); err != nil {
return err
}
if err := PublishUserAuthCache(user.Id); err != nil {
return err
}
_, err = RevokeAllUserSessions(user.Id, "password_reset")
return err
}
@@ -1074,7 +1169,7 @@ func GetUserGroup(id int, fromDB bool) (group string, err error) {
// Update Redis cache asynchronously on successful DB read
if shouldUpdateRedis(fromDB, err) {
gopool.Go(func() {
if err := updateUserGroupCache(id, group); err != nil {
if err := RefreshUserGroupCache(id); err != nil {
common.SysLog("failed to update user group cache: " + err.Error())
}
})
+283
View File
@@ -0,0 +1,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
}
+195 -10
View File
@@ -2,38 +2,48 @@ package model
import (
"context"
"encoding/base64"
"errors"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/go-redis/redis/v8"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) {
func TestHardDeleteUserFailsClosedWhenAuthFenceCannotPublish(t *testing.T) {
truncateTables(t)
user := User{Username: "hard-delete-user", Password: "password"}
user := User{Username: "hard-delete-user", Password: "password", TelegramId: "hard-delete-telegram"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-token"}).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error)
require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error)
require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "public-key"}).Error)
require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user"}).Error)
require.NoError(t, DB.Create(&UserSession{
SID: "hard-delete-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: 1, ExpiresAt: 2,
}).Error)
require.NoError(t, DB.Create(&AuthFlow{
TokenHash: "hard-delete-auth-flow", Purpose: AuthFlowPurposeTwoFALogin,
UserId: user.Id, ExpiresAt: time.Now().Add(time.Minute),
}).Error)
oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB
common.RedisEnabled = true
var cacheInvalidatedAfterCommit atomic.Bool
common.RDB = redis.NewClient(&redis.Options{
Dialer: func(context.Context, string, string) (net.Conn, error) {
var count int64
if err := DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error; err == nil && count == 0 {
cacheInvalidatedAfterCommit.Store(true)
}
return nil, errors.New("forced redis failure")
},
MaxRetries: -1,
@@ -43,8 +53,58 @@ func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) {
common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB
})
require.Error(t, HardDeleteUserById(user.Id))
var count int64
require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error)
assert.EqualValues(t, 1, count)
for _, record := range []any{
&Token{},
&TwoFA{},
&TwoFABackupCode{},
&PasskeyCredential{},
&UserOAuthBinding{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
} {
require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error)
assert.EqualValues(t, 1, count)
}
}
func TestHardDeleteUserPublishesTombstoneAndPurgesAuthenticationData(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "hard-delete-success", Password: "password", AuthVersion: 1,
TelegramId: "hard-delete-success-telegram",
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-success-token"}).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error)
require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error)
require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential-success", PublicKey: "public-key"}).Error)
require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user-success"}).Error)
require.NoError(t, DB.Create(&UserSession{
SID: "hard-delete-success-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: 1, ExpiresAt: 2,
}).Error)
require.NoError(t, DB.Create(&AuthFlow{
TokenHash: "hard-delete-success-flow", Purpose: AuthFlowPurposeTwoFALogin,
UserId: user.Id, ExpiresAt: time.Now().Add(time.Minute),
}).Error)
require.NoError(t, populateUserCache(user))
// Administrative hard deletion commonly targets an already soft-deleted
// user; the shared version increment must therefore query unscoped.
require.NoError(t, DB.Delete(&user).Error)
require.NoError(t, HardDeleteUserById(user.Id))
assert.True(t, cacheInvalidatedAfterCommit.Load())
var count int64
require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error)
@@ -55,10 +115,18 @@ func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) {
&TwoFABackupCode{},
&PasskeyCredential{},
&UserOAuthBinding{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
} {
require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error)
assert.Zero(t, count)
}
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
assert.False(t, server.Exists(getUserCacheKey(user.Id)))
}
func TestIncrementFailedAttemptsCountsConcurrentFailures(t *testing.T) {
@@ -94,7 +162,10 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
truncateTables(t)
const code = "ABCD-1234"
require.NoError(t, CreateBackupCodes(123, []string{code}))
user := User{Id: 123, Username: "backup-code-user", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}).Error)
require.NoError(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{code}))
const attempts = 2
results := make(chan bool, attempts)
@@ -128,3 +199,117 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
require.NoError(t, err)
assert.Zero(t, remaining)
}
func TestPendingTwoFASetupAPIsRejectEnabledFactor(t *testing.T) {
truncateTables(t)
user := User{Username: "enabled-twofa-guard", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}
require.NoError(t, DB.Create(&twoFA).Error)
require.Error(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{"ABCD-1234"}))
require.Error(t, twoFA.DeletePendingTwoFASetup())
var stored TwoFA
require.NoError(t, DB.First(&stored, twoFA.Id).Error)
assert.True(t, stored.IsEnabled)
var backupCodeCount int64
require.NoError(t, DB.Model(&TwoFABackupCode{}).Where("user_id = ?", user.Id).Count(&backupCodeCount).Error)
assert.Zero(t, backupCodeCount)
}
func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) {
truncateTables(t)
user := User{
Username: "security-factor-version-user",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}
require.NoError(t, DB.Create(&twoFA).Error)
require.NoError(t, twoFA.EnableWithAuthVersion())
assertUserAuthVersion(t, user.Id, 2)
assert.ErrorIs(t, twoFA.EnableWithAuthVersion(), ErrTwoFAAlreadyEnabled)
assertUserAuthVersion(t, user.Id, 2)
require.NoError(t, ReplaceBackupCodesWithAuthVersion(user.Id, []string{"ABCD-1234"}))
assertUserAuthVersion(t, user.Id, 3)
require.NoError(t, DisableTwoFAWithAuthVersion(user.Id))
assertUserAuthVersion(t, user.Id, 4)
credential := &PasskeyCredential{UserID: user.Id, CredentialID: "credential-id", PublicKey: "public-key"}
require.NoError(t, UpsertPasskeyCredentialWithAuthVersion(credential))
assertUserAuthVersion(t, user.Id, 5)
require.NoError(t, DeletePasskeyByUserIDWithAuthVersion(user.Id))
assertUserAuthVersion(t, user.Id, 6)
}
func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing.T) {
truncateTables(t)
user := User{Username: "passkey-assertion-state", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
credentialID := []byte("stable-credential-id")
stored := PasskeyCredential{
UserID: user.Id,
CredentialID: base64.StdEncoding.EncodeToString(credentialID),
PublicKey: "original-public-key",
AttestationType: "packed",
AAGUID: "original-aaguid",
SignCount: 1,
Transports: `["usb"]`,
Attachment: "platform",
}
require.NoError(t, DB.Create(&stored).Error)
usedAt := time.Now().UTC().Truncate(time.Second)
validated := &webauthn.Credential{
ID: credentialID,
PublicKey: []byte("replacement-public-key"),
AttestationType: "none",
Flags: webauthn.CredentialFlags{
UserPresent: true,
UserVerified: true,
BackupEligible: true,
BackupState: true,
},
Authenticator: webauthn.Authenticator{
AAGUID: []byte("replacement-aaguid"),
SignCount: 8,
CloneWarning: true,
},
}
require.NoError(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt))
var updated PasskeyCredential
require.NoError(t, DB.First(&updated, stored.ID).Error)
assert.Equal(t, stored.CredentialID, updated.CredentialID)
assert.Equal(t, stored.PublicKey, updated.PublicKey)
assert.Equal(t, stored.AttestationType, updated.AttestationType)
assert.Equal(t, stored.AAGUID, updated.AAGUID)
assert.Equal(t, stored.Transports, updated.Transports)
assert.Equal(t, stored.Attachment, updated.Attachment)
assert.EqualValues(t, 8, updated.SignCount)
assert.True(t, updated.CloneWarning)
assert.True(t, updated.UserPresent)
assert.True(t, updated.UserVerified)
assert.True(t, updated.BackupEligible)
assert.True(t, updated.BackupState)
require.NotNil(t, updated.LastUsedAt)
assert.Equal(t, usedAt.Unix(), updated.LastUsedAt.Unix())
validated.ID = []byte("another-credential")
assert.ErrorIs(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt), ErrPasskeyNotFound)
}
func assertUserAuthVersion(t *testing.T, userID int, expected int64) {
t.Helper()
var version int64
require.NoError(t, DB.Model(&User{}).Where("id = ?", userID).Select("auth_version").Scan(&version).Error)
assert.Equal(t, expected, version)
}
+105 -79
View File
@@ -1,27 +1,29 @@
package model
import (
"errors"
"fmt"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/gin-gonic/gin"
"github.com/bytedance/gopkg/util/gopool"
)
// UserBase struct remains the same as it represents the cached data structure
const userCacheSchemaVersion = 2
type UserBase struct {
Id int `json:"id"`
Group string `json:"group"`
Email string `json:"email"`
Quota int `json:"quota"`
Status int `json:"status"`
Username string `json:"username"`
Setting string `json:"setting"`
Id int `json:"id"`
Group string `json:"group"`
Email string `json:"email"`
Quota int `json:"quota"`
Status int `json:"status"`
Role int `json:"role"`
Username string `json:"username"`
Setting string `json:"setting"`
AuthVersion int64 `json:"-"`
CacheSchema int `json:"-"`
}
func (user *UserBase) WriteContext(c *gin.Context) {
@@ -49,6 +51,14 @@ func getUserCacheKey(userId int) string {
return fmt.Sprintf("user:%d", userId)
}
func userCacheTTLSeconds() int {
ttl := common.RedisKeyCacheSeconds()
if ttl <= 0 {
return 60
}
return ttl
}
// invalidateUserCache clears user cache
func invalidateUserCache(userId int) error {
if !common.RedisEnabled {
@@ -67,12 +77,7 @@ func populateUserCache(user User) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetObj(
getUserCacheKey(user.Id),
user.ToBaseUser(),
time.Duration(common.RedisKeyCacheSeconds())*time.Second,
)
return writeUserCache(user.ToBaseUser(), true)
}
// updateUserCache refreshes non-quota user cache fields.
@@ -82,61 +87,37 @@ func updateUserCache(user User) error {
if !common.RedisEnabled {
return nil
}
if err := updateUserGroupCache(user.Id, user.Group); err != nil {
return err
}
if err := updateUserEmailCache(user.Id, user.Email); err != nil {
return err
}
if err := updateUserStatusCache(user.Id, user.Status == common.UserStatusEnabled); err != nil {
return err
}
if err := updateUserNameCache(user.Id, user.Username); err != nil {
return err
}
return updateUserSettingCache(user.Id, user.Setting)
return writeUserCache(user.ToBaseUser(), false)
}
// GetUserCache gets complete user cache from hash
func GetUserCache(userId int) (userCache *UserBase, err error) {
var user *User
var fromDB bool
defer func() {
// Update Redis cache asynchronously on successful DB read
if shouldUpdateRedis(fromDB, err) && user != nil {
gopool.Go(func() {
if err := populateUserCache(*user); err != nil {
common.SysLog("failed to update user status cache: " + err.Error())
}
})
}
}()
func GetUserCache(userId int) (*UserBase, error) {
// Try getting from Redis first
userCache, err = cacheGetUserBase(userId)
userCache, err := cacheGetUserBase(userId)
if err == nil {
return userCache, nil
}
// If Redis fails, get from DB
fromDB = true
user, err = GetUserById(userId, false)
// Redis misses and read failures both fall back to the shared database. A
// version fence newer than the database is the one exception: allowing that
// snapshot would re-authorize a user while a restrictive update is pending.
user, err := GetUserById(userId, false)
if err != nil {
return nil, err // Return nil and error if DB lookup fails
return nil, err
}
// Create cache object from user data
userCache = &UserBase{
Id: user.Id,
Group: user.Group,
Quota: user.Quota,
Status: user.Status,
Username: user.Username,
Setting: user.Setting,
Email: user.Email,
if common.RedisEnabled {
floor, floorErr := getUserAuthVersionFloor(userId)
if floorErr == nil && floor > user.AuthVersion {
return nil, ErrUserAuthCachePending
}
if err := populateUserCache(*user); err != nil {
if errors.Is(err, ErrUserAuthCachePending) {
return nil, err
}
common.SysLog("failed to synchronously populate user cache: " + err.Error())
}
}
return userCache, nil
return user.ToBaseUser(), nil
}
func cacheGetUserBase(userId int) (*UserBase, error) {
@@ -149,6 +130,16 @@ func cacheGetUserBase(userId int) (*UserBase, error) {
if err != nil {
return nil, err
}
if userCache.Id != userId || userCache.CacheSchema != userCacheSchemaVersion || userCache.AuthVersion <= 0 {
return nil, fmt.Errorf("user cache schema is stale")
}
floor, err := getUserAuthVersionFloor(userId)
if err != nil {
return nil, err
}
if floor > userCache.AuthVersion {
return nil, ErrUserAuthCachePending
}
return &userCache, nil
}
@@ -207,14 +198,11 @@ func getUserSettingCache(userId int) (dto.UserSetting, error) {
// New functions for individual field updates
func updateUserStatusCache(userId int, status bool) error {
if !common.RedisEnabled {
return nil
}
statusInt := common.UserStatusEnabled
if !status {
statusInt = common.UserStatusDisabled
}
return common.RedisHSetField(getUserCacheKey(userId), "Status", fmt.Sprintf("%d", statusInt))
return updateUserCacheField(userId, "Status", statusInt)
}
func updateUserQuotaCache(userId int, quota int) error {
@@ -224,36 +212,74 @@ func updateUserQuotaCache(userId int, quota int) error {
return common.RedisHSetField(getUserCacheKey(userId), "Quota", fmt.Sprintf("%d", quota))
}
func updateUserGroupCache(userId int, group string) error {
// RefreshUserGroupCache writes the database-authoritative group into an
// existing user hash without changing the user's authentication version.
func RefreshUserGroupCache(userId int) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetField(getUserCacheKey(userId), "Group", group)
}
if userId <= 0 {
return fmt.Errorf("invalid user id")
}
var authoritative User
if err := DB.Select("id", "auth_version", commonGroupCol).Where("id = ?", userId).First(&authoritative).Error; err != nil {
return err
}
// Group transitions intentionally keep the same authentication version. A
// refresh that read the previous group can therefore arrive after a newer
// refresh and still pass the auth-version fence. Re-read after every write
// and repair the cache when the authoritative group changed in between.
for range 3 {
if err := updateUserCacheFieldAtVersion(userId, "Group", authoritative.Group, authoritative.AuthVersion); err != nil {
return err
}
func UpdateUserGroupCache(userId int, group string) error {
return updateUserGroupCache(userId, group)
var verified User
if err := DB.Select("id", "auth_version", commonGroupCol).Where("id = ?", userId).First(&verified).Error; err != nil {
return err
}
if verified.AuthVersion == authoritative.AuthVersion && verified.Group == authoritative.Group {
return nil
}
authoritative = verified
}
// Preserve the freshest snapshot observed even when the row was too busy to
// stabilize within the bounded retries. Returning an error lets best-effort
// callers emit an operation-specific warning.
if err := updateUserCacheFieldAtVersion(userId, "Group", authoritative.Group, authoritative.AuthVersion); err != nil {
return err
}
return fmt.Errorf("user group changed repeatedly during cache refresh")
}
func updateUserEmailCache(userId int, email string) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetField(getUserCacheKey(userId), "Email", email)
return updateUserCacheField(userId, "Email", email)
}
func updateUserNameCache(userId int, username string) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetField(getUserCacheKey(userId), "Username", username)
return updateUserCacheField(userId, "Username", username)
}
func updateUserSettingCache(userId int, setting string) error {
return updateUserCacheField(userId, "Setting", setting)
}
// updateUserCacheField prevents individual cache refreshes from bypassing the
// auth-version fence. It intentionally does nothing when the complete hash is
// absent; the next GetUserCache call will repopulate it from the database.
func updateUserCacheField(userId int, field string, value interface{}) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetField(getUserCacheKey(userId), "Setting", setting)
var user User
if err := DB.Select("id", "auth_version").Where("id = ?", userId).First(&user).Error; err != nil {
return err
}
if user.AuthVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
return updateUserCacheFieldAtVersion(userId, field, value, user.AuthVersion)
}
// GetUserLanguage returns the user's language preference from cache
+223
View File
@@ -0,0 +1,223 @@
package model
import (
"errors"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func useUserCacheMiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
server := miniredis.RunT(t)
oldRedisEnabled := common.RedisEnabled
oldRDB := common.RDB
oldSyncFrequency := common.SyncFrequency
common.RedisEnabled = true
common.SyncFrequency = 2
common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() {
_ = common.RDB.Close()
common.RedisEnabled = oldRedisEnabled
common.RDB = oldRDB
common.SyncFrequency = oldSyncFrequency
})
return server
}
func TestUserAuthFenceRollbackExpiresAndRecovers(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "auth-fence-rollback",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
tx := DB.Begin()
require.NoError(t, tx.Error)
next, err := IncrementUserAuthVersionWithTx(tx, user.Id)
require.NoError(t, err)
assert.EqualValues(t, 2, next)
_, err = cacheGetUserBase(user.Id)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
cacheTTL, err := common.RDB.TTL(t.Context(), getUserCacheKey(user.Id)).Result()
require.NoError(t, err)
fenceTTL, err := common.RDB.TTL(t.Context(), getUserAuthFenceKey(user.Id)).Result()
require.NoError(t, err)
assert.Greater(t, fenceTTL, cacheTTL)
require.NoError(t, tx.Rollback().Error)
server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second)
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "1", committed)
cached, err := GetUserCache(user.Id)
require.NoError(t, err)
assert.EqualValues(t, 1, cached.AuthVersion)
}
func TestPendingUserAuthFenceRejectsStaleCacheWrite(t *testing.T) {
server := useUserCacheMiniRedis(t)
const userID = 4201
require.NoError(t, SetUserAuthVersionFence(userID, 2))
err := writeUserCache(&UserBase{
Id: userID, Group: "default", Username: "stale", AuthVersion: 1,
}, true)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
assert.False(t, server.Exists(getUserCacheKey(userID)))
}
func TestUserAuthFieldUpdateRejectsVersionMismatch(t *testing.T) {
useUserCacheMiniRedis(t)
const userID = 4202
require.NoError(t, writeUserCache(&UserBase{
Id: userID, Group: "current", Username: "cached", AuthVersion: 3,
}, true))
err := updateUserCacheFieldAtVersion(userID, "Group", "stale", 2)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
group, err := common.RDB.HGet(t.Context(), getUserCacheKey(userID), "Group").Result()
require.NoError(t, err)
assert.Equal(t, "current", group)
}
func TestRefreshUserGroupCacheRepairsDelayedSameVersionWrite(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
user := User{
Username: "delayed-group-refresh",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
firstSnapshotRead := make(chan struct{})
releaseDelayedRefresh := make(chan struct{})
var intercepted atomic.Bool
const callbackName = "test:block_delayed_group_refresh"
require.NoError(t, DB.Callback().Query().After("gorm:query").Register(callbackName, func(*gorm.DB) {
if intercepted.CompareAndSwap(false, true) {
close(firstSnapshotRead)
<-releaseDelayedRefresh
}
}))
t.Cleanup(func() {
_ = DB.Callback().Query().Remove(callbackName)
})
delayedResult := make(chan error, 1)
go func() {
delayedResult <- RefreshUserGroupCache(user.Id)
}()
<-firstSnapshotRead
require.NoError(t, DB.Model(&User{}).Where("id = ?", user.Id).Update("group", "pro").Error)
require.NoError(t, RefreshUserGroupCache(user.Id))
cached, err := cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
close(releaseDelayedRefresh)
require.NoError(t, <-delayedResult)
cached, err = cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
}
func TestCommittedUserAuthVersionPermanentlyRejectsDelayedCacheFill(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "auth-fence-commit",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
stale := *user.ToBaseUser()
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
_, err := IncrementUserAuthVersionWithTx(tx, user.Id)
return err
}))
require.NoError(t, PublishUserAuthCache(user.Id))
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second)
require.NoError(t, common.RedisDelKey(getUserCacheKey(user.Id)))
err = writeUserCache(&stale, true)
assert.True(t, errors.Is(err, ErrUserAuthCachePending))
committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
}
func TestUserAuthVersionFenceAndCommittedFloorAreMonotonic(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
const userID = 4101
require.NoError(t, SetUserAuthVersionFence(userID, 5))
require.NoError(t, SetUserAuthVersionFence(userID, 3))
pending, err := common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", pending)
floor, err := getUserAuthVersionFloor(userID)
require.NoError(t, err)
assert.EqualValues(t, 5, floor)
// Committing an older transaction must neither clear a newer pending fence
// nor lower the effective deny floor.
require.NoError(t, publishCommittedUserAuthVersion(userID, 3))
pending, err = common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", pending)
floor, err = getUserAuthVersionFloor(userID)
require.NoError(t, err)
assert.EqualValues(t, 5, floor)
require.NoError(t, publishCommittedUserAuthVersion(userID, 5))
assert.False(t, server.Exists(getUserAuthFenceKey(userID)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", committed)
require.NoError(t, publishCommittedUserAuthVersion(userID, 4))
committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", committed)
}
+870
View File
@@ -0,0 +1,870 @@
package model
import (
"context"
"crypto/hmac"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
const (
UserSessionStatusActive = "active"
UserSessionStatusRevoking = "revoking"
UserSessionStatusRevoked = "revoked"
userSessionCacheSchema = 1
userSessionListLimit = 100
userSessionRevokeBatchSize = 500
userSessionCleanupScanLimit = 1000
userSessionCleanupBatchSize = 500
)
var (
ErrUserSessionInvalid = errors.New("user session is invalid")
ErrUserSessionInactive = errors.New("user session is inactive")
ErrUserSessionRefreshInvalid = errors.New("user session refresh token is invalid")
ErrUserSessionRefreshRace = errors.New("user session refresh is already in progress")
ErrUserSessionRefreshReuse = errors.New("user session refresh token was reused")
ErrUserSessionLimit = errors.New("active user session limit reached")
ErrUserSessionIssuanceLimit = errors.New("user session issuance limit reached")
errUserSessionCacheObservationStale = errors.New("user session cache observation is stale")
)
// UserSession is the server-side control plane for short-lived access JWTs.
// RefreshHash values are HMAC digests supplied by the service layer; opaque
// refresh secrets are never persisted.
type UserSession struct {
SID string `json:"sid" gorm:"column:sid;type:varchar(64);primaryKey"`
UserID int `json:"user_id" gorm:"column:user_id;not null;index:idx_user_sessions_user_status_expiry,priority:1;index:idx_user_sessions_user_created,priority:1"`
Version int64 `json:"version" gorm:"type:bigint;not null;default:1"`
UserAuthVersion int64 `json:"user_auth_version" gorm:"type:bigint;not null"`
Status string `json:"status" gorm:"type:varchar(16);not null;index:idx_user_sessions_user_status_expiry,priority:2;index:idx_user_sessions_status_revoked,priority:1"`
RefreshHash string `json:"-" gorm:"type:char(64);not null"`
PreviousRefreshHash string `json:"-" gorm:"type:varchar(64)"`
PreviousValidUntil int64 `json:"-" gorm:"type:bigint;not null;default:0"`
LoginMethod string `json:"login_method" gorm:"type:varchar(32);not null"`
IP string `json:"ip" gorm:"type:varchar(64)"`
UserAgent string `json:"user_agent" gorm:"type:text"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at;index:idx_user_sessions_user_created,priority:2"`
LastActiveAt int64 `json:"last_active_at" gorm:"type:bigint;not null;column:last_active_at"`
ExpiresAt int64 `json:"expires_at" gorm:"type:bigint;not null;column:expires_at;index:idx_user_sessions_user_status_expiry,priority:3;index:idx_user_sessions_expires_at"`
RevokedAt int64 `json:"revoked_at,omitempty" gorm:"type:bigint;not null;default:0;column:revoked_at;index:idx_user_sessions_status_revoked,priority:2"`
RevokedReason string `json:"revoked_reason,omitempty" gorm:"type:varchar(64);column:revoked_reason"`
}
func (UserSession) TableName() string {
return "user_sessions"
}
func (session *UserSession) AfterFind(_ *gorm.DB) error {
session.PreviousRefreshHash = strings.TrimSpace(session.PreviousRefreshHash)
return nil
}
type userSessionCacheEntry struct {
SID string
UserID int
Version int64
UserAuthVersion int64
Status string
LoginMethod string
IP string
UserAgent string
CreatedAt int64
LastActiveAt int64
ExpiresAt int64
RevokedAt int64
RevokedReason string
CacheSchema int
}
func (session *UserSession) cacheEntry() *userSessionCacheEntry {
return &userSessionCacheEntry{
SID: session.SID,
UserID: session.UserID,
Version: session.Version,
UserAuthVersion: session.UserAuthVersion,
Status: session.Status,
LoginMethod: session.LoginMethod,
IP: session.IP,
UserAgent: session.UserAgent,
CreatedAt: session.CreatedAt,
LastActiveAt: session.LastActiveAt,
ExpiresAt: session.ExpiresAt,
RevokedAt: session.RevokedAt,
RevokedReason: session.RevokedReason,
CacheSchema: userSessionCacheSchema,
}
}
func (entry *userSessionCacheEntry) session() *UserSession {
return &UserSession{
SID: entry.SID,
UserID: entry.UserID,
Version: entry.Version,
UserAuthVersion: entry.UserAuthVersion,
Status: entry.Status,
LoginMethod: entry.LoginMethod,
IP: entry.IP,
UserAgent: entry.UserAgent,
CreatedAt: entry.CreatedAt,
LastActiveAt: entry.LastActiveAt,
ExpiresAt: entry.ExpiresAt,
RevokedAt: entry.RevokedAt,
RevokedReason: entry.RevokedReason,
}
}
func userSessionCacheKey(sid string) string {
digest := common.GenerateHMACWithKey([]byte("user-session-cache-v1:"+common.SessionSecret), sid)
return "auth:session:" + digest
}
func userSessionCacheDeadline() time.Time {
return time.Now().Add(time.Duration(userCacheTTLSeconds()) * time.Second)
}
func CreateUserSession(session *UserSession) error {
now := time.Now().Unix()
if session == nil || session.SID == "" || session.UserID <= 0 || session.UserAuthVersion <= 0 || session.RefreshHash == "" || session.ExpiresAt <= now {
return ErrUserSessionInvalid
}
if session.Version <= 0 {
session.Version = 1
}
if session.Status == "" {
session.Status = UserSessionStatusActive
}
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 {
return ErrUserSessionInvalid
}
if session.LastActiveAt == 0 {
session.LastActiveAt = now
}
if session.CreatedAt == 0 {
session.CreatedAt = now
}
cacheDeadline := userSessionCacheDeadline()
if err := DB.Create(session).Error; err != nil {
return err
}
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
return confirmUserSessionActiveSnapshot(session)
}
if errors.Is(err, ErrUserSessionInactive) {
return err
}
common.SysLog("failed to populate newly created user session cache: " + err.Error())
}
return nil
}
func CountActiveUserSessions(userID int, now int64) (int64, error) {
if userID <= 0 {
return 0, ErrUserSessionInvalid
}
if now <= 0 {
now = time.Now().Unix()
}
var count int64
err := DB.Model(&UserSession{}).
Where("user_id = ? AND status = ? AND expires_at > ?", userID, UserSessionStatusActive, now).
Count(&count).Error
return count, err
}
// CountUserSessionsCreatedSince counts every issued row, regardless of its
// current status or expiry. userID zero selects the global count.
func CountUserSessionsCreatedSince(userID int, createdAfter int64) (int64, error) {
if userID < 0 || createdAfter <= 0 {
return 0, ErrUserSessionInvalid
}
query := DB.Model(&UserSession{}).Where("created_at > ?", createdAfter)
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
var count int64
err := query.Count(&count).Error
return count, err
}
func GetUserSessionBySID(sid string) (*UserSession, error) {
if sid == "" {
return nil, ErrUserSessionInvalid
}
var session UserSession
if err := DB.Where("sid = ?", sid).First(&session).Error; err != nil {
return nil, err
}
return &session, nil
}
// GetUserSessionCached validates cached state first and falls back to the
// database on a miss or Redis read failure. A deny tombstone never falls back.
func GetUserSessionCached(sid string) (*UserSession, error) {
if sid == "" {
return nil, ErrUserSessionInvalid
}
if common.RedisEnabled {
entry, err := getUserSessionCache(sid)
if err == nil {
return entry.session(), nil
}
if errors.Is(err, ErrUserSessionInactive) {
return nil, err
}
}
cacheDeadline := userSessionCacheDeadline()
session, err := GetUserSessionBySID(sid)
if err != nil {
return nil, err
}
now := time.Now().Unix()
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now {
if common.RedisEnabled {
entry := session.cacheEntry()
entry.Status = UserSessionStatusRevoked
_ = writeUserSessionCache(entry, time.Time{})
}
return nil, ErrUserSessionInactive
}
if common.RedisEnabled {
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
if confirmErr := confirmUserSessionActiveSnapshot(session); confirmErr != nil {
return nil, confirmErr
}
return session, nil
}
if errors.Is(err, ErrUserSessionInactive) {
return nil, err
}
common.SysLog("failed to synchronously populate user session cache: " + err.Error())
}
}
return session, nil
}
func getUserSessionCache(sid string) (*userSessionCacheEntry, error) {
var entry userSessionCacheEntry
if err := common.RedisHGetObj(userSessionCacheKey(sid), &entry); err != nil {
return nil, err
}
if entry.CacheSchema != userSessionCacheSchema || entry.SID != sid || entry.UserID <= 0 || entry.Version <= 0 || entry.UserAuthVersion <= 0 {
return nil, fmt.Errorf("user session cache schema is stale")
}
if entry.Status != UserSessionStatusActive || entry.RevokedAt != 0 || entry.ExpiresAt <= time.Now().Unix() {
return nil, ErrUserSessionInactive
}
return &entry, nil
}
// writeUserSessionCache writes a bounded Session snapshot. Active snapshots
// must carry a deadline captured immediately before their authoritative
// database read or mutation. Delayed fills inherit the unspent portion of that
// window, so a stale active snapshot cannot outlive a short deny tombstone and
// reactivate a revoked Session after the tombstone expires. Deny states pass a
// zero deadline because their TTL starts when they are published.
func writeUserSessionCache(entry *userSessionCacheEntry, cacheDeadline time.Time) error {
if entry == nil || !common.RedisEnabled {
return nil
}
now := time.Now()
sessionExpiresAt := time.Unix(entry.ExpiresAt, 0)
sessionTTL := sessionExpiresAt.Sub(now)
var redisExpiration int64
if entry.Status == UserSessionStatusActive {
if cacheDeadline.IsZero() {
return ErrUserSessionInvalid
}
cacheTTL := cacheDeadline.Sub(now)
if cacheTTL <= 0 {
return errUserSessionCacheObservationStale
}
if sessionTTL <= 0 {
return ErrUserSessionInactive
}
cacheExpiresAt := cacheDeadline
if sessionExpiresAt.Before(cacheExpiresAt) {
cacheExpiresAt = sessionExpiresAt
}
if cacheExpiresAt.Sub(now) < time.Millisecond {
return errUserSessionCacheObservationStale
}
redisExpiration = cacheExpiresAt.UnixMilli()
} else {
ttl := min(sessionTTL, time.Duration(userCacheTTLSeconds())*time.Second)
if ttl <= 0 {
ttl = time.Second
}
redisExpiration = ttl.Milliseconds()
if redisExpiration <= 0 {
redisExpiration = 1
}
}
entry.CacheSchema = userSessionCacheSchema
const script = `
local current_status = redis.call('HGET', KEYS[1], 'Status')
local current_version = tonumber(redis.call('HGET', KEYS[1], 'Version') or '0')
if ARGV[5] == 'active' and (current_status == 'revoking' or current_status == 'revoked') then
return 0
end
if current_version > tonumber(ARGV[3]) then
return 0
end
redis.call('HSET', KEYS[1],
'SID', ARGV[1], 'UserID', ARGV[2], 'Version', ARGV[3],
'UserAuthVersion', ARGV[4], 'Status', ARGV[5],
'LoginMethod', ARGV[6], 'IP', ARGV[7], 'UserAgent', ARGV[8],
'CreatedAt', ARGV[9], 'LastActiveAt', ARGV[10], 'ExpiresAt', ARGV[11],
'RevokedAt', ARGV[12], 'RevokedReason', ARGV[13], 'CacheSchema', ARGV[14])
if ARGV[5] == 'active' then
redis.call('PEXPIREAT', KEYS[1], ARGV[15])
else
redis.call('PEXPIRE', KEYS[1], ARGV[15])
end
return 1`
result, err := common.RDB.Eval(context.Background(), script, []string{userSessionCacheKey(entry.SID)},
entry.SID, entry.UserID, entry.Version, entry.UserAuthVersion, entry.Status,
entry.LoginMethod, entry.IP, entry.UserAgent, entry.CreatedAt, entry.LastActiveAt,
entry.ExpiresAt, entry.RevokedAt, entry.RevokedReason, entry.CacheSchema, redisExpiration,
).Int()
if err != nil {
return err
}
if result == 0 {
return ErrUserSessionInactive
}
if entry.Status == UserSessionStatusActive {
completedAt := time.Now()
if !completedAt.Before(cacheDeadline) {
return errUserSessionCacheObservationStale
}
if !completedAt.Before(sessionExpiresAt) {
return ErrUserSessionInactive
}
}
return nil
}
func confirmUserSessionActiveSnapshot(session *UserSession) error {
if session == nil || session.SID == "" || session.UserID <= 0 || session.Version <= 0 || session.UserAuthVersion <= 0 {
return ErrUserSessionInvalid
}
var count int64
err := DB.Model(&UserSession{}).
Where(
"sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ? AND version = ? AND user_auth_version = ?",
session.SID,
session.UserID,
UserSessionStatusActive,
0,
time.Now().Unix(),
session.Version,
session.UserAuthVersion,
).
Count(&count).Error
if err != nil {
return err
}
if count != 1 {
return ErrUserSessionInactive
}
return nil
}
func writeUserSessionDenyFence(session *UserSession, status string, now int64, reason string) error {
if !common.RedisEnabled {
return nil
}
entry := session.cacheEntry()
entry.Status = status
entry.RevokedAt = now
entry.RevokedReason = reason
return writeUserSessionCache(entry, time.Time{})
}
func ListActiveUserSessions(userID int, currentSID string, now int64) ([]UserSession, error) {
if userID <= 0 {
return nil, ErrUserSessionInvalid
}
if now <= 0 {
now = time.Now().Unix()
}
var authVersion int64
if err := DB.Model(&User{}).Where("id = ?", userID).Select("auth_version").Find(&authVersion).Error; err != nil {
return nil, err
}
if authVersion <= 0 {
return nil, ErrUserSessionInvalid
}
sessions := make([]UserSession, 0, userSessionListLimit)
if currentSID != "" {
var current []UserSession
if err := DB.Where(
"user_id = ? AND user_auth_version = ? AND status = ? AND expires_at > ? AND sid = ?",
userID,
authVersion,
UserSessionStatusActive,
now,
currentSID,
).Limit(1).Find(&current).Error; err != nil {
return nil, err
}
if len(current) == 1 {
sessions = append(sessions, current[0])
}
}
remainingLimit := userSessionListLimit - len(sessions)
otherQuery := DB.Where(
"user_id = ? AND user_auth_version = ? AND status = ? AND expires_at > ?",
userID,
authVersion,
UserSessionStatusActive,
now,
)
if currentSID != "" {
otherQuery = otherQuery.Where("sid <> ?", currentSID)
}
var others []UserSession
if err := otherQuery.Order("last_active_at DESC").Order("created_at DESC").Limit(remainingLimit).Find(&others).Error; err != nil {
return nil, err
}
sessions = append(sessions, others...)
return sessions, nil
}
// RotateUserSessionRefresh atomically rotates HMAC digests. The UPDATE itself
// is a compare-and-swap so SQLite, where lockForUpdate is intentionally a
// no-op, has the same single-winner behavior as MySQL and PostgreSQL. Only a
// recognized previous digest outside its grace window is treated as reuse;
// an unknown secret never revokes the victim session.
func RotateUserSessionRefresh(userID int, sid, presentedHash, nextHash string, now int64, grace time.Duration) (*UserSession, error) {
if userID <= 0 || sid == "" || presentedHash == "" || nextHash == "" || hmac.Equal([]byte(presentedHash), []byte(nextHash)) {
return nil, ErrUserSessionInvalid
}
if now <= 0 {
now = time.Now().Unix()
}
graceSeconds := int64(grace / time.Second)
if graceSeconds < 0 {
return nil, ErrUserSessionInvalid
}
for range 3 {
cacheDeadline := userSessionCacheDeadline()
var session UserSession
if err := DB.Where("sid = ? AND user_id = ?", sid, userID).First(&session).Error; err != nil {
return nil, err
}
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now {
return nil, ErrUserSessionInactive
}
if hmac.Equal([]byte(session.RefreshHash), []byte(presentedHash)) {
result := DB.Model(&UserSession{}).
Where("sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ? AND refresh_hash = ?",
sid, userID, UserSessionStatusActive, 0, now, presentedHash).
Updates(map[string]interface{}{
"previous_refresh_hash": session.RefreshHash,
"previous_valid_until": now + graceSeconds,
"refresh_hash": nextHash,
"last_active_at": now,
})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
continue
}
session.PreviousRefreshHash = session.RefreshHash
session.PreviousValidUntil = now + graceSeconds
session.RefreshHash = nextHash
session.LastActiveAt = now
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
if confirmErr := confirmUserSessionActiveSnapshot(&session); confirmErr != nil {
return nil, confirmErr
}
} else if errors.Is(err, ErrUserSessionInactive) {
return nil, err
} else {
common.SysLog("failed to update rotated user session cache: " + err.Error())
}
}
return &session, nil
}
if session.PreviousRefreshHash == "" || !hmac.Equal([]byte(session.PreviousRefreshHash), []byte(presentedHash)) {
return nil, ErrUserSessionRefreshInvalid
}
if now <= session.PreviousValidUntil {
return &session, ErrUserSessionRefreshRace
}
// Once a known previous token is replayed outside the grace window the
// whole token family is compromised. Publish the deny fence first, then
// revoke the active row regardless of a concurrent refresh rotation.
if err := writeUserSessionDenyFence(&session, UserSessionStatusRevoking, now, "refresh_reuse"); err != nil {
return nil, err
}
result := DB.Model(&UserSession{}).
Where("sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ?",
sid, userID, UserSessionStatusActive, 0, now).
Updates(map[string]interface{}{
"status": UserSessionStatusRevoked,
"revoked_at": now,
"revoked_reason": "refresh_reuse",
})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, ErrUserSessionInactive
}
session.Status = UserSessionStatusRevoked
session.RevokedAt = now
session.RevokedReason = "refresh_reuse"
if err := writeUserSessionCache(session.cacheEntry(), time.Time{}); err != nil {
common.SysLog("failed to cache refresh-reuse session revoke: " + err.Error())
}
return nil, ErrUserSessionRefreshReuse
}
return nil, ErrUserSessionRefreshInvalid
}
func RevokeUserSession(userID int, sid, reason string) (bool, error) {
if userID <= 0 || sid == "" {
return false, ErrUserSessionInvalid
}
now := time.Now().Unix()
var candidate UserSession
if err := DB.Where("sid = ? AND user_id = ?", sid, userID).First(&candidate).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
if candidate.Status != UserSessionStatusActive || candidate.RevokedAt != 0 || candidate.ExpiresAt <= now {
return false, nil
}
if err := writeUserSessionDenyFence(&candidate, UserSessionStatusRevoking, now, reason); err != nil {
return false, err
}
var revoked bool
err := DB.Transaction(func(tx *gorm.DB) error {
var current UserSession
if err := lockForUpdate(tx).Where("sid = ? AND user_id = ?", sid, userID).First(&current).Error; err != nil {
return err
}
if current.Status != UserSessionStatusActive || current.RevokedAt != 0 || current.ExpiresAt <= now {
return nil
}
result := tx.Model(&UserSession{}).Where("sid = ? AND status = ?", sid, UserSessionStatusActive).Updates(map[string]interface{}{
"status": UserSessionStatusRevoked,
"revoked_at": now,
"revoked_reason": reason,
})
if result.Error != nil {
return result.Error
}
revoked = result.RowsAffected == 1
return nil
})
if err != nil {
return false, err
}
if revoked {
candidate.Status = UserSessionStatusRevoked
candidate.RevokedAt = now
candidate.RevokedReason = reason
if err := writeUserSessionCache(candidate.cacheEntry(), time.Time{}); err != nil {
common.SysLog("failed to finalize user session revoke tombstone: " + err.Error())
}
}
return revoked, nil
}
// RevokeUserSessionByRefreshHash is used when logout is authenticated only by
// the HttpOnly refresh cookie. Possession of a SID alone is insufficient. The
// immediately previous digest is accepted only inside the refresh race window.
func RevokeUserSessionByRefreshHash(sid, presentedHash, reason string) (bool, error) {
if sid == "" || presentedHash == "" {
return false, ErrUserSessionInvalid
}
now := time.Now().Unix()
var session UserSession
var revoked bool
err := DB.Transaction(func(tx *gorm.DB) error {
if err := lockForUpdate(tx).Where("sid = ?", sid).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now {
return nil
}
validCurrent := hmac.Equal([]byte(session.RefreshHash), []byte(presentedHash))
validPrevious := session.PreviousRefreshHash != "" && now <= session.PreviousValidUntil &&
hmac.Equal([]byte(session.PreviousRefreshHash), []byte(presentedHash))
if !validCurrent && !validPrevious {
return nil
}
if err := writeUserSessionDenyFence(&session, UserSessionStatusRevoking, now, reason); err != nil {
return err
}
result := tx.Model(&UserSession{}).Where("sid = ? AND status = ?", sid, UserSessionStatusActive).Updates(map[string]interface{}{
"status": UserSessionStatusRevoked,
"revoked_at": now,
"revoked_reason": reason,
})
if result.Error != nil {
return result.Error
}
revoked = result.RowsAffected == 1
if revoked {
session.Status = UserSessionStatusRevoked
session.RevokedAt = now
session.RevokedReason = reason
}
return nil
})
if err != nil {
return false, err
}
if revoked {
if err := writeUserSessionCache(session.cacheEntry(), time.Time{}); err != nil {
common.SysLog("failed to finalize refresh-authenticated session revoke tombstone: " + err.Error())
}
}
return revoked, nil
}
// AdvanceUserSessionAuthVersion preserves one browser session across a
// user-level security-version change. Both old access JWTs and concurrent
// updates are invalidated by advancing the per-session version as well.
func AdvanceUserSessionAuthVersion(userID int, sid string, expectedSessionVersion, expectedUserAuthVersion, nextUserAuthVersion int64) (*UserSession, error) {
if userID <= 0 || sid == "" || expectedSessionVersion <= 0 || expectedUserAuthVersion <= 0 || nextUserAuthVersion <= expectedUserAuthVersion {
return nil, ErrUserSessionInvalid
}
cacheDeadline := userSessionCacheDeadline()
now := time.Now().Unix()
var session UserSession
err := DB.Transaction(func(tx *gorm.DB) error {
if err := lockForUpdate(tx).Where("sid = ? AND user_id = ?", sid, userID).First(&session).Error; err != nil {
return err
}
if session.Status != UserSessionStatusActive || session.ExpiresAt <= now ||
session.Version != expectedSessionVersion || session.UserAuthVersion != expectedUserAuthVersion {
return ErrUserSessionInactive
}
session.Version++
session.UserAuthVersion = nextUserAuthVersion
session.LastActiveAt = now
result := tx.Model(&UserSession{}).
Where("sid = ? AND status = ? AND version = ? AND user_auth_version = ?", sid, UserSessionStatusActive, expectedSessionVersion, expectedUserAuthVersion).
Updates(map[string]interface{}{
"version": session.Version,
"user_auth_version": session.UserAuthVersion,
"last_active_at": session.LastActiveAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrUserSessionInactive
}
return nil
})
if err != nil {
return nil, err
}
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
if confirmErr := confirmUserSessionActiveSnapshot(&session); confirmErr != nil {
return nil, confirmErr
}
} else {
return nil, err
}
}
return &session, nil
}
func RevokeOtherUserSessions(userID int, currentSID, reason string) (int64, error) {
return revokeUserSessions(userID, currentSID, reason)
}
func RevokeAllUserSessions(userID int, reason string) (int64, error) {
return revokeUserSessions(userID, "", reason)
}
func revokeUserSessions(userID int, excludedSID, reason string) (int64, error) {
if userID <= 0 {
return 0, ErrUserSessionInvalid
}
now := time.Now().Unix()
var totalAffected int64
for {
query := DB.Where("user_id = ? AND status = ? AND expires_at > ?", userID, UserSessionStatusActive, now)
if excludedSID != "" {
query = query.Where("sid <> ?", excludedSID)
}
var candidates []UserSession
if err := query.Order("sid").Limit(userSessionRevokeBatchSize).Find(&candidates).Error; err != nil {
return totalAffected, err
}
if len(candidates) == 0 {
return totalAffected, nil
}
for i := range candidates {
if err := writeUserSessionDenyFence(&candidates[i], UserSessionStatusRevoking, now, reason); err != nil {
return totalAffected, err
}
}
sids := make([]string, 0, len(candidates))
for i := range candidates {
sids = append(sids, candidates[i].SID)
}
var affected int64
var revoked []UserSession
err := DB.Transaction(func(tx *gorm.DB) error {
if err := lockForUpdate(tx).Where("sid IN ? AND status = ?", sids, UserSessionStatusActive).Find(&revoked).Error; err != nil {
return err
}
if len(revoked) == 0 {
return nil
}
lockedSIDs := make([]string, 0, len(revoked))
for i := range revoked {
lockedSIDs = append(lockedSIDs, revoked[i].SID)
}
result := tx.Model(&UserSession{}).Where("sid IN ? AND status = ?", lockedSIDs, UserSessionStatusActive).Updates(map[string]interface{}{
"status": UserSessionStatusRevoked,
"revoked_at": now,
"revoked_reason": reason,
})
affected = result.RowsAffected
return result.Error
})
if err != nil {
return totalAffected, err
}
totalAffected += affected
for i := range revoked {
revoked[i].Status = UserSessionStatusRevoked
revoked[i].RevokedAt = now
revoked[i].RevokedReason = reason
if err := writeUserSessionCache(revoked[i].cacheEntry(), time.Time{}); err != nil {
common.SysLog("failed to finalize bulk user session revoke tombstone: " + err.Error())
}
}
}
}
func DeleteExpiredUserSessions(now int64) error {
if now <= 0 {
now = time.Now().Unix()
}
if common.UserSessionRevokedRetentionDays <= 0 || common.UserSessionIssuanceWindowSeconds <= 0 {
return ErrUserSessionInvalid
}
issuanceCutoff := now - common.UserSessionIssuanceWindowSeconds
revokedBefore := now - int64(common.UserSessionRevokedRetentionDays)*24*60*60
return deleteExpiredUserSessionsBefore(now, issuanceCutoff, revokedBefore)
}
func DeleteOldRevokedUserSessions(now int64) error {
if now <= 0 {
now = time.Now().Unix()
}
if common.UserSessionRevokedRetentionDays <= 0 || common.UserSessionIssuanceWindowSeconds <= 0 {
return ErrUserSessionInvalid
}
issuanceCutoff := now - common.UserSessionIssuanceWindowSeconds
revokedBefore := now - int64(common.UserSessionRevokedRetentionDays)*24*60*60
return deleteRevokedUserSessionsBefore(revokedBefore, issuanceCutoff)
}
func deleteExpiredUserSessionsBefore(expiredBefore, issuanceCutoff, revokedBefore int64) error {
for {
var sids []string
if err := DB.Model(&UserSession{}).
Where(
"expires_at < ? AND created_at <= ? AND (status <> ? OR revoked_at <= 0 OR revoked_at < ?)",
expiredBefore,
issuanceCutoff,
UserSessionStatusRevoked,
revokedBefore,
).
Order("expires_at").Limit(userSessionCleanupScanLimit).Pluck("sid", &sids).Error; err != nil {
return err
}
if len(sids) == 0 {
return nil
}
for start := 0; start < len(sids); start += userSessionCleanupBatchSize {
end := start + userSessionCleanupBatchSize
if end > len(sids) {
end = len(sids)
}
if err := DB.Where("sid IN ?", sids[start:end]).
Where(
"expires_at < ? AND created_at <= ? AND (status <> ? OR revoked_at <= 0 OR revoked_at < ?)",
expiredBefore,
issuanceCutoff,
UserSessionStatusRevoked,
revokedBefore,
).
Delete(&UserSession{}).Error; err != nil {
return err
}
}
}
}
func deleteRevokedUserSessionsBefore(revokedBefore, issuanceCutoff int64) error {
for {
var sids []string
if err := DB.Model(&UserSession{}).
Where(
"status = ? AND revoked_at > 0 AND revoked_at < ? AND created_at <= ?",
UserSessionStatusRevoked,
revokedBefore,
issuanceCutoff,
).
Order("revoked_at").Limit(userSessionCleanupScanLimit).Pluck("sid", &sids).Error; err != nil {
return err
}
if len(sids) == 0 {
return nil
}
for start := 0; start < len(sids); start += userSessionCleanupBatchSize {
end := start + userSessionCleanupBatchSize
if end > len(sids) {
end = len(sids)
}
if err := DB.Where("sid IN ?", sids[start:end]).
Where(
"status = ? AND revoked_at > 0 AND revoked_at < ? AND created_at <= ?",
UserSessionStatusRevoked,
revokedBefore,
issuanceCutoff,
).
Delete(&UserSession{}).Error; err != nil {
return err
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package model
import (
"context"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type previousRefreshHashMigrationLegacy struct {
SID string `gorm:"column:sid;type:varchar(64);primaryKey"`
PreviousRefreshHash string `gorm:"column:previous_refresh_hash;type:char(64)"`
}
type previousRefreshHashMigrationTarget struct {
SID string `gorm:"column:sid;type:varchar(64);primaryKey"`
PreviousRefreshHash string `gorm:"column:previous_refresh_hash;type:varchar(64)"`
}
type migrationSQLRecorder struct {
mu sync.Mutex
statements []string
}
func (recorder *migrationSQLRecorder) LogMode(logger.LogLevel) logger.Interface { return recorder }
func (recorder *migrationSQLRecorder) Info(context.Context, string, ...any) {}
func (recorder *migrationSQLRecorder) Warn(context.Context, string, ...any) {}
func (recorder *migrationSQLRecorder) Error(context.Context, string, ...any) {}
func (recorder *migrationSQLRecorder) Trace(_ context.Context, _ time.Time, sql func() (string, int64), _ error) {
statement, _ := sql()
recorder.mu.Lock()
recorder.statements = append(recorder.statements, statement)
recorder.mu.Unlock()
}
func (recorder *migrationSQLRecorder) reset() {
recorder.mu.Lock()
recorder.statements = nil
recorder.mu.Unlock()
}
func (recorder *migrationSQLRecorder) schemaMutations() []string {
recorder.mu.Lock()
defer recorder.mu.Unlock()
mutations := make([]string, 0)
for _, statement := range recorder.statements {
normalized := strings.ToUpper(strings.TrimSpace(statement))
if strings.HasPrefix(normalized, "ALTER TABLE") ||
strings.HasPrefix(normalized, "CREATE TABLE") ||
strings.HasPrefix(normalized, "DROP TABLE") ||
strings.HasPrefix(normalized, "RENAME TABLE") {
mutations = append(mutations, statement)
}
}
return mutations
}
func TestUserSessionPreviousRefreshHashSchemaUsesNullableVarchar(t *testing.T) {
statement := &gorm.Statement{DB: DB}
require.NoError(t, statement.Parse(&UserSession{}))
field := statement.Schema.LookUpField("PreviousRefreshHash")
require.NotNil(t, field)
assert.Equal(t, "varchar(64)", field.TagSettings["TYPE"])
assert.False(t, field.NotNull)
}
func testPreviousRefreshHashMigration(t *testing.T, db *gorm.DB, recorder *migrationSQLRecorder, dialect string) {
t.Helper()
tableName := fmt.Sprintf("user_session_previous_hash_migration_%d", time.Now().UnixNano())
t.Cleanup(func() { _ = db.Migrator().DropTable(tableName) })
require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationLegacy{}))
digest := strings.Repeat("a", 60)
require.NoError(t, db.Table(tableName).Create(&previousRefreshHashMigrationLegacy{
SID: "legacy-session",
PreviousRefreshHash: digest,
}).Error)
require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationTarget{}))
var session UserSession
require.NoError(t, db.Table(tableName).
Select("sid", "previous_refresh_hash").
Where("sid = ?", "legacy-session").
First(&session).Error)
assert.Equal(t, digest, session.PreviousRefreshHash, "legacy CHAR padding must be normalized on database reads")
columnTypes, err := db.Table(tableName).Migrator().ColumnTypes(&previousRefreshHashMigrationTarget{})
require.NoError(t, err)
var previousHashColumnFound bool
for _, columnType := range columnTypes {
if !strings.EqualFold(columnType.Name(), "previous_refresh_hash") {
continue
}
previousHashColumnFound = true
nullable, ok := columnType.Nullable()
require.True(t, ok)
if dialect != "sqlite" {
assert.True(t, nullable)
}
assert.Contains(t, strings.ToUpper(columnType.DatabaseTypeName()), "VARCHAR")
}
assert.True(t, previousHashColumnFound)
recorder.reset()
require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationTarget{}))
assert.Empty(t, recorder.schemaMutations(), "a second migration must not repeat type-changing DDL")
}
func TestUserSessionPreviousRefreshHashMigrationSQLite(t *testing.T) {
recorder := &migrationSQLRecorder{}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: recorder})
require.NoError(t, err)
testPreviousRefreshHashMigration(t, db, recorder, "sqlite")
}
func TestUserSessionPreviousRefreshHashMigrationConfiguredDatabases(t *testing.T) {
tests := []struct {
name string
env string
dialector func(string) gorm.Dialector
}{
{name: "mysql", env: "TEST_MYSQL_DSN", dialector: func(dsn string) gorm.Dialector { return mysql.Open(dsn) }},
{name: "postgres", env: "TEST_POSTGRES_DSN", dialector: func(dsn string) gorm.Dialector {
return postgres.New(postgres.Config{DSN: dsn, PreferSimpleProtocol: true})
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv(test.env))
if dsn == "" {
t.Skip(test.env + " is not configured")
}
recorder := &migrationSQLRecorder{}
db, err := gorm.Open(test.dialector(dsn), &gorm.Config{Logger: recorder})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
testPreviousRefreshHashMigration(t, db, recorder, test.name)
})
}
}
+655
View File
@@ -0,0 +1,655 @@
package model
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type setMiniRedisTimeOnEvalHook struct {
server *miniredis.Miniredis
at time.Time
}
func (hook setMiniRedisTimeOnEvalHook) BeforeProcess(ctx context.Context, cmd redis.Cmder) (context.Context, error) {
if cmd.Name() == "eval" {
hook.server.SetTime(hook.at)
}
return ctx, nil
}
func (setMiniRedisTimeOnEvalHook) AfterProcess(context.Context, redis.Cmder) error {
return nil
}
func (setMiniRedisTimeOnEvalHook) BeforeProcessPipeline(ctx context.Context, _ []redis.Cmder) (context.Context, error) {
return ctx, nil
}
func (setMiniRedisTimeOnEvalHook) AfterProcessPipeline(context.Context, []redis.Cmder) error {
return nil
}
func setupUserSessionTest(t *testing.T) {
t.Helper()
require.NoError(t, DB.AutoMigrate(&User{}, &UserSession{}))
require.NoError(t, DB.Exec("DELETE FROM user_sessions").Error)
oldRedisEnabled := common.RedisEnabled
oldActiveLimit := common.UserSessionActiveLimit
oldIssuanceLimit := common.UserSessionIssuanceLimit
oldIssuanceWindow := common.UserSessionIssuanceWindowSeconds
oldRevokedRetention := common.UserSessionRevokedRetentionDays
common.RedisEnabled = false
common.UserSessionActiveLimit = common.DefaultUserSessionActiveLimit
common.UserSessionIssuanceLimit = common.DefaultUserSessionIssuanceLimit
common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds)
common.UserSessionRevokedRetentionDays = common.DefaultUserSessionRevokedRetentionDays
t.Cleanup(func() {
common.RedisEnabled = oldRedisEnabled
common.UserSessionActiveLimit = oldActiveLimit
common.UserSessionIssuanceLimit = oldIssuanceLimit
common.UserSessionIssuanceWindowSeconds = oldIssuanceWindow
common.UserSessionRevokedRetentionDays = oldRevokedRetention
})
}
func createUserSessionTestUser(t *testing.T, userID int, authVersion int64) {
t.Helper()
user := User{
Id: userID,
Username: fmt.Sprintf("user-session-%d", userID),
Password: "unused",
Status: common.UserStatusEnabled,
Role: common.RoleCommonUser,
Group: "default",
AffCode: fmt.Sprintf("session-aff-%d", userID),
AuthVersion: authVersion,
}
require.NoError(t, DB.Create(&user).Error)
t.Cleanup(func() { _ = DB.Unscoped().Delete(&User{}, userID).Error })
}
func newTestUserSession(sid string, userID int, now int64) *UserSession {
return &UserSession{
SID: sid,
UserID: userID,
Version: 1,
UserAuthVersion: 1,
Status: UserSessionStatusActive,
RefreshHash: fmt.Sprintf("current-%s", sid),
LoginMethod: "password",
IP: "127.0.0.1",
UserAgent: "model-test",
CreatedAt: now,
LastActiveAt: now,
ExpiresAt: now + int64((30*24*time.Hour)/time.Second),
}
}
func TestUserSessionCacheTTLUsesShortCacheWindow(t *testing.T) {
setupUserSessionTest(t)
server := useUserCacheMiniRedis(t)
now := time.Now().Unix()
tests := []struct {
name string
status string
expiresAt int64
wantMaxTTL time.Duration
}{
{name: "active", status: UserSessionStatusActive, expiresAt: now + 300, wantMaxTTL: 2 * time.Second},
{name: "revoking", status: UserSessionStatusRevoking, expiresAt: now + 300, wantMaxTTL: 2 * time.Second},
{name: "revoked", status: UserSessionStatusRevoked, expiresAt: now + 300, wantMaxTTL: 2 * time.Second},
{name: "already expired", status: UserSessionStatusRevoked, expiresAt: now - 1, wantMaxTTL: time.Second},
}
for index, test := range tests {
sid := fmt.Sprintf("short-cache-ttl-%d", index)
entry := newTestUserSession(sid, 1100+index, now).cacheEntry()
entry.Status = test.status
entry.ExpiresAt = test.expiresAt
if test.status != UserSessionStatusActive {
entry.RevokedAt = now
}
cacheDeadline := time.Time{}
if test.status == UserSessionStatusActive {
cacheDeadline = userSessionCacheDeadline()
}
require.NoError(t, writeUserSessionCache(entry, cacheDeadline), test.name)
ttl := server.TTL(userSessionCacheKey(sid))
assert.Positive(t, ttl, test.name)
assert.LessOrEqual(t, ttl, test.wantMaxTTL, test.name)
}
initialTTL := server.TTL(userSessionCacheKey("short-cache-ttl-0"))
server.FastForward(time.Second)
_, err := getUserSessionCache("short-cache-ttl-0")
require.NoError(t, err)
remainingTTL := server.TTL(userSessionCacheKey("short-cache-ttl-0"))
assert.Positive(t, remainingTTL)
assert.LessOrEqual(t, remainingTTL, initialTTL-time.Second, "cache reads must not renew the bounded TTL")
common.SyncFrequency = 10
nearExpiry := newTestUserSession("short-cache-ttl-near-expiry", 1199, now).cacheEntry()
nearExpiry.ExpiresAt = time.Now().Add(2 * time.Second).Unix()
nearExpiryDeadline := userSessionCacheDeadline()
remainingLifetime := time.Until(time.Unix(nearExpiry.ExpiresAt, 0))
require.NoError(t, writeUserSessionCache(nearExpiry, nearExpiryDeadline))
nearExpiryTTL := server.TTL(userSessionCacheKey(nearExpiry.SID))
assert.Positive(t, nearExpiryTTL)
assert.LessOrEqual(t, nearExpiryTTL, remainingLifetime, "cache TTL must not exceed the Session remaining lifetime")
common.SyncFrequency = 0
fallback := newTestUserSession("short-cache-ttl-fallback", 1200, now).cacheEntry()
fallback.ExpiresAt = now + 300
require.NoError(t, writeUserSessionCache(fallback, userSessionCacheDeadline()))
fallbackTTL := server.TTL(userSessionCacheKey(fallback.SID))
assert.Greater(t, fallbackTTL, 59*time.Second)
assert.LessOrEqual(t, fallbackTTL, 60*time.Second, "non-positive cache frequency must use the existing 60-second fallback")
}
func TestStaleActiveSessionCacheFillCannotRestartWindowAfterDenyExpires(t *testing.T) {
setupUserSessionTest(t)
server := useUserCacheMiniRedis(t)
now := time.Now().Unix()
active := newTestUserSession("stale-active-cache-fill", 1201, now).cacheEntry()
denied := *active
denied.Status = UserSessionStatusRevoked
denied.RevokedAt = now
denied.RevokedReason = "test-revoke"
require.NoError(t, writeUserSessionCache(&denied, time.Time{}))
cacheKey := userSessionCacheKey(active.SID)
assert.True(t, server.Exists(cacheKey))
server.FastForward(3 * time.Second)
assert.False(t, server.Exists(cacheKey), "the short deny tombstone must have expired in this race setup")
err := writeUserSessionCache(active, time.Now().Add(-time.Millisecond))
assert.ErrorIs(t, err, errUserSessionCacheObservationStale)
assert.False(t, server.Exists(cacheKey), "a delayed pre-revoke active snapshot must not restart a fresh cache window")
}
func TestActiveSessionCacheFillUsesRemainingObservationWindow(t *testing.T) {
setupUserSessionTest(t)
server := useUserCacheMiniRedis(t)
now := time.Now().Unix()
entry := newTestUserSession("bounded-active-cache-fill", 1202, now).cacheEntry()
deadline := time.Now().Add(1500 * time.Millisecond)
require.NoError(t, writeUserSessionCache(entry, deadline))
ttl := server.TTL(userSessionCacheKey(entry.SID))
assert.Positive(t, ttl)
assert.LessOrEqual(t, ttl, 1500*time.Millisecond, "a delayed fill must inherit only the unused observation window")
}
func TestSessionCacheLuaUsesAbsoluteActiveAndRelativeDenyExpiry(t *testing.T) {
setupUserSessionTest(t)
server := useUserCacheMiniRedis(t)
now := time.Now().Unix()
deadline := time.Now().Add(10 * time.Second)
common.RDB.AddHook(setMiniRedisTimeOnEvalHook{server: server, at: deadline.Add(time.Second)})
active := newTestUserSession("delayed-active-cache-eval", 1203, now).cacheEntry()
require.NoError(t, writeUserSessionCache(active, deadline))
assert.False(t, server.Exists(userSessionCacheKey(active.SID)), "an active fill executed after its absolute deadline must not recreate the cache")
denied := newTestUserSession("delayed-deny-cache-eval", 1204, now).cacheEntry()
denied.Status = UserSessionStatusRevoked
denied.RevokedAt = now
denied.RevokedReason = "test-revoke"
require.NoError(t, writeUserSessionCache(denied, time.Time{}))
denyTTL := server.TTL(userSessionCacheKey(denied.SID))
assert.Positive(t, denyTTL)
assert.LessOrEqual(t, denyTTL, 2*time.Second, "a delayed deny publication must receive a full relative short TTL at Redis execution")
}
func TestUserSessionCreateListAndRevokeOne(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
user := User{Id: 1001, Username: "session-list-user", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
t.Cleanup(func() { _ = DB.Unscoped().Delete(&User{}, user.Id).Error })
first := newTestUserSession("session-one", 1001, now)
second := newTestUserSession("session-two", 1001, now+1)
require.NoError(t, CreateUserSession(first))
require.NoError(t, CreateUserSession(second))
sessions, err := ListActiveUserSessions(1001, first.SID, now)
require.NoError(t, err)
require.Len(t, sessions, 2)
assert.Equal(t, first.SID, sessions[0].SID)
revoked, err := RevokeUserSession(1001, first.SID, "user_revoked")
require.NoError(t, err)
assert.True(t, revoked)
revoked, err = RevokeUserSession(1001, first.SID, "duplicate")
require.NoError(t, err)
assert.False(t, revoked)
_, err = GetUserSessionCached(first.SID)
assert.ErrorIs(t, err, ErrUserSessionInactive)
active, err := GetUserSessionCached(second.SID)
require.NoError(t, err)
assert.Equal(t, second.SID, active.SID)
}
func TestRotateUserSessionRefreshRaceAndReuse(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
createUserSessionTestUser(t, 1002, 1)
session := newTestUserSession("rotate-session", 1002, now)
require.NoError(t, CreateUserSession(session))
rotated, err := RotateUserSessionRefresh(1002, session.SID, session.RefreshHash, "next-hash", now+10, 30*time.Second)
require.NoError(t, err)
assert.Equal(t, "next-hash", rotated.RefreshHash)
assert.Equal(t, session.RefreshHash, rotated.PreviousRefreshHash)
assert.Equal(t, now+40, rotated.PreviousValidUntil)
_, err = RotateUserSessionRefresh(1002, session.SID, session.RefreshHash, "unused-hash", now+20, 30*time.Second)
assert.ErrorIs(t, err, ErrUserSessionRefreshRace)
_, err = RotateUserSessionRefresh(1002, session.SID, "unknown-hash", "unused-hash", now+20, 30*time.Second)
assert.ErrorIs(t, err, ErrUserSessionRefreshInvalid)
stored, getErr := GetUserSessionBySID(session.SID)
require.NoError(t, getErr)
assert.Equal(t, UserSessionStatusActive, stored.Status)
_, err = RotateUserSessionRefresh(1002, session.SID, session.RefreshHash, "unused-hash", now+41, 30*time.Second)
assert.ErrorIs(t, err, ErrUserSessionRefreshReuse)
stored, getErr = GetUserSessionBySID(session.SID)
require.NoError(t, getErr)
assert.Equal(t, UserSessionStatusRevoked, stored.Status)
assert.Equal(t, "refresh_reuse", stored.RevokedReason)
}
func TestUserSessionPreviousRefreshHashNormalizesLegacyPadding(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
createUserSessionTestUser(t, 1010, 1)
digest := strings.Repeat("a", 64)
blank := newTestUserSession("legacy-blank-previous-hash", 1010, now)
blank.PreviousRefreshHash = strings.Repeat(" ", 64)
blank.PreviousValidUntil = now + 60
require.NoError(t, DB.Create(blank).Error)
loadedBlank, err := GetUserSessionBySID(blank.SID)
require.NoError(t, err)
assert.Empty(t, loadedBlank.PreviousRefreshHash)
valid := newTestUserSession("legacy-valid-previous-hash", 1010, now)
valid.RefreshHash = strings.Repeat("b", 64)
valid.PreviousRefreshHash = digest
valid.PreviousValidUntil = now + 60
require.NoError(t, DB.Create(valid).Error)
loadedValid, err := GetUserSessionBySID(valid.SID)
require.NoError(t, err)
assert.Equal(t, digest, loadedValid.PreviousRefreshHash)
require.NoError(t, DB.Model(&UserSession{}).Where("sid = ?", valid.SID).
Updates(map[string]any{
"previous_refresh_hash": digest + " ",
"previous_valid_until": now + 60,
}).Error)
_, err = RotateUserSessionRefresh(valid.UserID, valid.SID, digest, strings.Repeat("c", 64), now+1, 30*time.Second)
assert.ErrorIs(t, err, ErrUserSessionRefreshRace)
revoked, err := RevokeUserSessionByRefreshHash(valid.SID, digest, "legacy-padded-refresh-logout")
require.NoError(t, err)
assert.True(t, revoked, "refresh-cookie logout must accept a legacy CHAR-padded previous digest inside its grace window")
}
func TestUserSessionCacheExcludesRefreshDigests(t *testing.T) {
setupUserSessionTest(t)
useUserCacheMiniRedis(t)
now := time.Now().Unix()
session := newTestUserSession("cache-without-refresh-digests", 1011, now)
session.PreviousRefreshHash = strings.Repeat("a", 64)
session.PreviousValidUntil = now + 30
require.NoError(t, writeUserSessionCache(session.cacheEntry(), userSessionCacheDeadline()))
cacheKey := userSessionCacheKey(session.SID)
fields, err := common.RDB.HGetAll(context.Background(), cacheKey).Result()
require.NoError(t, err)
assert.NotContains(t, fields, "RefreshHash")
assert.NotContains(t, fields, "PreviousRefreshHash")
assert.NotContains(t, fields, "PreviousValidUntil")
require.NoError(t, common.RDB.HSet(context.Background(), cacheKey,
"RefreshHash", strings.Repeat("b", 64),
"PreviousRefreshHash", strings.Repeat("c", 64)+" ",
"PreviousValidUntil", now+30,
).Err())
entry, err := getUserSessionCache(session.SID)
require.NoError(t, err)
cachedSession := entry.session()
assert.Empty(t, cachedSession.RefreshHash)
assert.Empty(t, cachedSession.PreviousRefreshHash)
assert.Zero(t, cachedSession.PreviousValidUntil)
}
func TestRevokeOtherUserSessionsKeepsCurrent(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
createUserSessionTestUser(t, 1003, 1)
createUserSessionTestUser(t, 1004, 1)
for _, sid := range []string{"current-session", "other-one", "other-two"} {
session := newTestUserSession(sid, 1003, now)
if sid == "other-one" {
session.UserAuthVersion = 99
}
require.NoError(t, CreateUserSession(session))
}
require.NoError(t, CreateUserSession(newTestUserSession("different-user", 1004, now)))
count, err := RevokeOtherUserSessions(1003, "current-session", "revoke_others")
require.NoError(t, err)
assert.Equal(t, int64(2), count)
current, err := GetUserSessionCached("current-session")
require.NoError(t, err)
assert.Equal(t, UserSessionStatusActive, current.Status)
_, err = GetUserSessionCached("other-one")
assert.True(t, errors.Is(err, ErrUserSessionInactive))
stale, err := GetUserSessionBySID("other-one")
require.NoError(t, err)
assert.Equal(t, UserSessionStatusRevoked, stale.Status, "revocation must include active sessions from stale auth versions")
different, err := GetUserSessionCached("different-user")
require.NoError(t, err)
assert.Equal(t, 1004, different.UserID)
}
func TestRevokeUserSessionByRefreshHashRequiresSecret(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
createUserSessionTestUser(t, 1005, 1)
session := newTestUserSession("refresh-logout-session", 1005, now)
require.NoError(t, CreateUserSession(session))
revoked, err := RevokeUserSessionByRefreshHash(session.SID, "wrong-hash", "logout")
require.NoError(t, err)
assert.False(t, revoked)
active, err := GetUserSessionCached(session.SID)
require.NoError(t, err)
assert.Equal(t, UserSessionStatusActive, active.Status)
revoked, err = RevokeUserSessionByRefreshHash(session.SID, session.RefreshHash, "logout")
require.NoError(t, err)
assert.True(t, revoked)
_, err = GetUserSessionCached(session.SID)
assert.ErrorIs(t, err, ErrUserSessionInactive)
}
func TestUserSessionGrowthCountsUseBroadActiveAndStrictIssuancePredicates(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
createUserSessionTestUser(t, 1006, 7)
rows := []UserSession{
*newTestUserSession("count-current-version", 1006, now-10),
*newTestUserSession("count-stale-version", 1006, now-9),
*newTestUserSession("count-expired", 1006, now-8),
*newTestUserSession("count-revoked", 1006, now-7),
*newTestUserSession("count-cutoff", 1006, now-3600),
}
rows[0].UserAuthVersion = 7
rows[1].UserAuthVersion = 2
rows[2].UserAuthVersion = 7
rows[2].ExpiresAt = now
rows[3].UserAuthVersion = 7
rows[3].Status = UserSessionStatusRevoked
rows[3].RevokedAt = now - 1
rows[4].UserAuthVersion = 7
rows[4].CreatedAt = now - 3600
rows[4].ExpiresAt = now
require.NoError(t, DB.Create(&rows).Error)
activeCount, err := CountActiveUserSessions(1006, now)
require.NoError(t, err)
assert.Equal(t, int64(2), activeCount, "active count includes stale auth versions but excludes expired and revoked rows")
issuedCount, err := CountUserSessionsCreatedSince(1006, now-3600)
require.NoError(t, err)
assert.Equal(t, int64(4), issuedCount, "issuance count includes every status and uses a strict cutoff")
globalCount, err := CountUserSessionsCreatedSince(0, now-3600)
require.NoError(t, err)
assert.Equal(t, issuedCount, globalCount)
}
func TestListActiveUserSessionsKeepsCurrentAndBoundsOtherSessions(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
createUserSessionTestUser(t, 1007, 7)
current := newTestUserSession("list-current", 1007, now-1000)
current.UserAuthVersion = 7
rows := make([]UserSession, 0, 107)
rows = append(rows, *current)
for i := 0; i < 105; i++ {
session := newTestUserSession(fmt.Sprintf("list-other-%03d", i), 1007, now-int64(i))
session.UserAuthVersion = 7
rows = append(rows, *session)
}
stale := newTestUserSession("list-stale-auth-version", 1007, now+1)
stale.UserAuthVersion = 6
rows = append(rows, *stale)
require.NoError(t, DB.CreateInBatches(rows, 100).Error)
sessions, err := ListActiveUserSessions(1007, current.SID, now)
require.NoError(t, err)
require.Len(t, sessions, 100)
assert.Equal(t, current.SID, sessions[0].SID)
for _, session := range sessions {
assert.Equal(t, int64(7), session.UserAuthVersion)
assert.NotEqual(t, stale.SID, session.SID)
}
sessionsWithoutCurrent, err := ListActiveUserSessions(1007, "missing-current", now)
require.NoError(t, err)
assert.Len(t, sessionsWithoutCurrent, userSessionListLimit, "a missing current SID must not reduce the total list limit")
}
func TestRevokeUserSessionsReturnsCumulativeProgressAndSupportsRetry(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
createUserSessionTestUser(t, 1008, 1)
rows := make([]UserSession, 0, userSessionRevokeBatchSize+1)
for i := 0; i < userSessionRevokeBatchSize+1; i++ {
rows = append(rows, *newTestUserSession(fmt.Sprintf("batch-revoke-%03d", i), 1008, now))
}
require.NoError(t, DB.CreateInBatches(rows, 100).Error)
forcedErr := errors.New("forced second revoke batch failure")
callbackName := "test:fail_second_user_session_revoke_batch"
updateCalls := 0
callbackRegistered := true
require.NoError(t, DB.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "user_sessions" {
updateCalls++
if updateCalls == 2 {
tx.AddError(forcedErr)
}
}
}))
t.Cleanup(func() {
if callbackRegistered {
_ = DB.Callback().Update().Remove(callbackName)
}
})
affected, err := RevokeAllUserSessions(1008, "batch-test")
assert.ErrorIs(t, err, forcedErr)
assert.Equal(t, int64(userSessionRevokeBatchSize), affected)
require.NoError(t, DB.Callback().Update().Remove(callbackName))
callbackRegistered = false
retried, err := RevokeAllUserSessions(1008, "batch-test-retry")
require.NoError(t, err)
assert.Equal(t, int64(1), retried)
var activeCount int64
require.NoError(t, DB.Model(&UserSession{}).Where("user_id = ? AND status = ?", 1008, UserSessionStatusActive).Count(&activeCount).Error)
assert.Zero(t, activeCount)
}
func TestDeleteExpiredUserSessionsLoopsInChunksAndRechecksPredicate(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
common.UserSessionRevokedRetentionDays = 7
common.UserSessionIssuanceWindowSeconds = 3600
oldCreatedAt := now - 7200
rows := make([]UserSession, 0, userSessionCleanupScanLimit+5)
race := newTestUserSession("cleanup-race", 1009, now-1000)
race.CreatedAt = oldCreatedAt
race.ExpiresAt = now - 1000
rows = append(rows, *race)
for i := 0; i < userSessionCleanupScanLimit+1; i++ {
session := newTestUserSession(fmt.Sprintf("cleanup-expired-%04d", i), 1009, now-100)
session.CreatedAt = oldCreatedAt - int64(i)
session.ExpiresAt = now - 100
rows = append(rows, *session)
}
oldRevoked := newTestUserSession("cleanup-old-revoked", 1009, now-10)
oldRevoked.CreatedAt = oldCreatedAt
oldRevoked.Status = UserSessionStatusRevoked
oldRevoked.RevokedAt = now - int64(8*24*time.Hour/time.Second)
rows = append(rows, *oldRevoked)
recentRevoked := newTestUserSession("cleanup-recent-revoked", 1009, now-9)
recentRevoked.CreatedAt = oldCreatedAt
recentRevoked.Status = UserSessionStatusRevoked
recentRevoked.RevokedAt = now - int64(6*24*time.Hour/time.Second)
recentRevoked.ExpiresAt = now - 100
rows = append(rows, *recentRevoked)
recentIssuedExpired := newTestUserSession("cleanup-recent-issued-expired", 1009, now-1800)
recentIssuedExpired.ExpiresAt = now - 100
rows = append(rows, *recentIssuedExpired)
expiryBoundary := newTestUserSession("cleanup-expiry-boundary", 1009, now-7)
expiryBoundary.CreatedAt = oldCreatedAt
expiryBoundary.ExpiresAt = now
rows = append(rows, *expiryBoundary)
revokedBoundary := newTestUserSession("cleanup-revoked-boundary", 1009, now-6)
revokedBoundary.CreatedAt = oldCreatedAt
revokedBoundary.Status = UserSessionStatusRevoked
revokedBoundary.RevokedAt = now - int64(7*24*time.Hour/time.Second)
rows = append(rows, *revokedBoundary)
live := newTestUserSession("cleanup-live", 1009, now-8)
rows = append(rows, *live)
require.NoError(t, DB.CreateInBatches(rows, 100).Error)
callbackName := "test:recheck_user_session_cleanup_predicate"
deleteCalls := 0
mutated := false
require.NoError(t, DB.Callback().Delete().Before("gorm:delete").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement == nil || tx.Statement.Table != "user_sessions" {
return
}
deleteCalls++
if !mutated {
mutated = true
tx.Exec("UPDATE user_sessions SET expires_at = ? WHERE sid = ?", now+3600, race.SID)
}
}))
t.Cleanup(func() { _ = DB.Callback().Delete().Remove(callbackName) })
require.NoError(t, DeleteExpiredUserSessions(now))
require.NoError(t, DeleteOldRevokedUserSessions(now))
assert.Equal(t, 4, deleteCalls, "expired and retained-revoked scans each delete in bounded chunks")
var remaining []UserSession
require.NoError(t, DB.Order("sid").Find(&remaining).Error)
require.Len(t, remaining, 6)
remainingSIDs := make([]string, 0, len(remaining))
for _, session := range remaining {
remainingSIDs = append(remainingSIDs, session.SID)
}
assert.ElementsMatch(t, []string{
race.SID,
recentRevoked.SID,
recentIssuedExpired.SID,
expiryBoundary.SID,
revokedBoundary.SID,
live.SID,
}, remainingSIDs)
}
func TestUserSessionGrowthQueryIndexesExist(t *testing.T) {
setupUserSessionTest(t)
migrator := DB.Migrator()
assert.True(t, migrator.HasIndex(&UserSession{}, "idx_user_sessions_expires_at"))
assert.True(t, migrator.HasIndex(&UserSession{}, "idx_user_sessions_user_created"))
assert.True(t, migrator.HasIndex(&UserSession{}, "idx_user_sessions_status_revoked"))
}
func TestUserBaseIncludesAuthorizationFields(t *testing.T) {
user := User{
Id: 42,
Username: "cache-user",
Role: common.RoleAdminUser,
Status: common.UserStatusEnabled,
Group: "vip",
Quota: 123,
AuthVersion: 7,
}
base := user.ToBaseUser()
assert.Equal(t, user.Role, base.Role)
assert.Equal(t, user.AuthVersion, base.AuthVersion)
assert.Equal(t, userCacheSchemaVersion, base.CacheSchema)
assert.Equal(t, user.Quota, base.Quota)
}
func TestUserUpdateBumpsAuthVersionOnlyForAuthorizationChanges(t *testing.T) {
setupUserSessionTest(t)
user := &User{
Username: "auth-version-user",
Password: "hashed-placeholder",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
}
require.NoError(t, DB.Create(user).Error)
t.Cleanup(func() { _ = DB.Unscoped().Delete(&User{}, user.Id).Error })
assert.Equal(t, int64(1), user.AuthVersion)
user.DisplayName = "profile-only"
require.NoError(t, user.Update(false))
assert.Equal(t, int64(1), user.AuthVersion)
user.Group = "vip"
require.NoError(t, user.Update(false))
assert.Equal(t, int64(2), user.AuthVersion)
user.Role = common.RoleAdminUser
require.NoError(t, user.Update(false))
assert.Equal(t, int64(3), user.AuthVersion)
}
func TestPasswordResetBumpsAuthVersionAndRevokesSessions(t *testing.T) {
setupUserSessionTest(t)
now := time.Now().Unix()
user := &User{
Username: "password-reset-user",
Password: "old-hash",
Email: "password-reset@example.com",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
}
require.NoError(t, DB.Create(user).Error)
t.Cleanup(func() { _ = DB.Unscoped().Delete(&User{}, user.Id).Error })
session := newTestUserSession("password-reset-session", user.Id, now)
require.NoError(t, CreateUserSession(session))
require.NoError(t, ResetUserPasswordByEmail(user.Email, "new-password"))
var stored User
require.NoError(t, DB.First(&stored, user.Id).Error)
assert.Equal(t, int64(2), stored.AuthVersion)
storedSession, err := GetUserSessionBySID(session.SID)
require.NoError(t, err)
assert.Equal(t, UserSessionStatusRevoked, storedSession.Status)
assert.Equal(t, "password_reset", storedSession.RevokedReason)
}