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:
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(¤t, user.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(¤t).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(¤t).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(¤t, 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(¤t).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())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¤t).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(¤t).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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user