Ctrl K
ComunidadGuías…

model/user_access_token.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package model

import (
	"errors"
	"strconv"
	"strings"
	"sync/atomic"
	"time"

	"github.com/QuantumNous/new-api/common"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

const (
	AccessTokenPrefix                = "nap_"
	AccessTokenSessionPrefix         = "pat:"
	AccessTokenSessionVersion        = 1
	legacyAccessTokenRetireAtKey     = "LegacyAccessTokenRetireAt"
	legacyAccessTokenTransitionDays  = 30
	userAccessTokenCleanupBatchSize  = 500
	userAccessTokenTouchIntervalSecs = 60
)

var (
	ErrAccessTokenNotFound      = errors.New("access token not found")
	ErrAccessTokenLimit         = errors.New("access token limit reached")
	ErrAccessTokenExpired       = errors.New("access token has expired")
	ErrLegacyAccessTokenRetired = errors.New("legacy access token has been retired")
	errLegacyRetireAtReadOnly   = errors.New("LegacyAccessTokenRetireAt is managed by the server")
)

// legacyAccessTokenRetireAt caches the deployment-wide deadline loaded at
// startup. Zero means it was never loaded, which is treated as retired.
var legacyAccessTokenRetireAt atomic.Int64

// UserAccessToken is a scoped dashboard personal access token. Only the
// SHA-256 fingerprint of the plaintext is stored; it doubles as the audit
// token_ref.
type UserAccessToken struct {
	Id         int    `json:"id"`
	UserId     int    `json:"-" gorm:"index"`
	Name       string `json:"name" gorm:"type:varchar(64)"`
	TokenHash  string `json:"token_ref" gorm:"type:varchar(64);uniqueIndex"`
	TokenHint  string `json:"token_hint" gorm:"type:varchar(8)"`
	Scopes     string `json:"-" gorm:"type:varchar(2048)"`
	ExpiresAt  int64  `json:"expires_at" gorm:"type:bigint;index"`
	LastUsedAt int64  `json:"last_used_at" gorm:"type:bigint"`
	LastUsedIp string `json:"last_used_ip" gorm:"type:varchar(64)"`
	CreatedAt  int64  `json:"created_at" gorm:"type:bigint"`
}

func (UserAccessToken) TableName() string {
	return "user_access_tokens"
}

// GetScopes returns the stored scope list; a malformed value grants nothing.
func (token *UserAccessToken) GetScopes() []string {
	if token == nil || token.Scopes == "" {
		return nil
	}
	var scopes []string
	if err := common.UnmarshalJsonStr(token.Scopes, &scopes); err != nil {
		common.SysError(common.LogText("invalid access token scopes: %s", err.Error()))
		return nil
	}
	return scopes
}

func (token *UserAccessToken) SetScopes(scopes []string) error {
	data, err := common.Marshal(scopes)
	if err != nil {
		return err
	}
	token.Scopes = string(data)
	return nil
}

// Expired reports whether the token has an absolute expiry at or before now.
func (token *UserAccessToken) Expired(now int64) bool {
	return token.ExpiresAt > 0 && token.ExpiresAt <= now
}

func FindUserAccessTokenByHash(hash string) (*UserAccessToken, error) {
	if hash == "" {
		return nil, nil
	}
	var token UserAccessToken
	err := DB.Where("token_hash = ?", hash).First(&token).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return &token, nil
}

func GetUserAccessToken(userID, id int) (*UserAccessToken, error) {
	var token UserAccessToken
	err := DB.Where("id = ? AND user_id = ?", id, userID).First(&token).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, ErrAccessTokenNotFound
	}
	if err != nil {
		return nil, err
	}
	return &token, nil
}

func ListUserAccessTokens(userID int) ([]UserAccessToken, error) {
	tokens := make([]UserAccessToken, 0)
	err := DB.Where("user_id = ?", userID).Order("created_at DESC").Order("id DESC").Find(&tokens).Error
	return tokens, err
}

// CreateUserAccessToken inserts a token while holding the owner's users row, so
// concurrent creates cannot exceed limit. Expired rows awaiting cleanup count.
func CreateUserAccessToken(userID int, token *UserAccessToken, limit int) error {
	if userID <= 0 || token == nil || token.TokenHash == "" {
		return ErrAccessTokenNotFound
	}
	return DB.Transaction(func(tx *gorm.DB) error {
		var user User
		if err := lockForUpdate(tx).Select("id").First(&user, userID).Error; err != nil {
			return err
		}
		var count int64
		if err := tx.Model(&UserAccessToken{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
			return err
		}
		if count >= int64(limit) {
			return ErrAccessTokenLimit
		}
		token.Id = 0
		token.UserId = userID
		if token.CreatedAt == 0 {
			token.CreatedAt = common.GetTimestamp()
		}
		return tx.Create(token).Error
	})
}

// UpdateUserAccessToken renames one token owned by userID and, when scopes is
// not nil, replaces its grant.
func UpdateUserAccessToken(userID, id int, name string, scopes []string) (*UserAccessToken, error) {
	updates := map[string]any{"name": name}
	if scopes != nil {
		var grant UserAccessToken
		if err := grant.SetScopes(scopes); err != nil {
			return nil, err
		}
		updates["scopes"] = grant.Scopes
	}
	result := DB.Model(&UserAccessToken{}).Where("id = ? AND user_id = ?", id, userID).Updates(updates)
	if result.Error != nil {
		return nil, result.Error
	}
	token, err := GetUserAccessToken(userID, id)
	if err != nil {
		return nil, err
	}
	return token, nil
}

// DeleteUserAccessToken hard-deletes one token owned by userID and returns the
// deleted row for auditing.
func DeleteUserAccessToken(userID, id int) (*UserAccessToken, error) {
	var token UserAccessToken
	err := DB.Transaction(func(tx *gorm.DB) error {
		if err := lockForUpdate(tx).Where("id = ? AND user_id = ?", id, userID).First(&token).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return ErrAccessTokenNotFound
			}
			return err
		}
		return tx.Where("id = ? AND user_id = ?", id, userID).Delete(&UserAccessToken{}).Error
	})
	if err != nil {
		return nil, err
	}
	return &token, nil
}

func DeleteUserAccessTokensWithTx(tx *gorm.DB, userID int) (int64, error) {
	result := tx.Where("user_id = ?", userID).Delete(&UserAccessToken{})
	return result.RowsAffected, result.Error
}

// TouchUserAccessToken records the last use at most once per interval per token.
func TouchUserAccessToken(id int, ip string, now int64) error {
	if len(ip) > 64 {
		ip = ip[:64]
	}
	return DB.Model(&UserAccessToken{}).
		Where("id = ? AND last_used_at < ?", id, now-userAccessTokenTouchIntervalSecs).
		Updates(map[string]any{"last_used_at": now, "last_used_ip": ip}).Error
}

// DeleteExpiredUserAccessTokens removes tokens that expired before the cutoff.
// Tokens that never expire are kept.
func DeleteExpiredUserAccessTokens(before int64) error {
	for {
		var ids []int
		if err := DB.Model(&UserAccessToken{}).
			Where("expires_at > 0 AND expires_at < ?", before).
			Order("id").Limit(userAccessTokenCleanupBatchSize).Pluck("id", &ids).Error; err != nil {
			return err
		}
		if len(ids) == 0 {
			return nil
		}
		if err := DB.Where("id IN ?", ids).Delete(&UserAccessToken{}).Error; err != nil {
			return err
		}
		if len(ids) < userAccessTokenCleanupBatchSize {
			return nil
		}
	}
}

// AccessTokenHint is the last four characters of a token, shown so users can
// tell their tokens apart. PostgreSQL pads legacy CHAR(32) values with spaces.
func AccessTokenHint(token string) string {
	token = strings.TrimRight(token, " ")
	if len(token) <= 4 {
		return ""
	}
	return token[len(token)-4:]
}

// AccessTokenSessionID is the step-up session identifier bound to a token.
// Browser session IDs are UUIDs and can never carry this prefix.
func AccessTokenSessionID(id int) string {
	return AccessTokenSessionPrefix + strconv.Itoa(id)
}

func ParseAccessTokenSessionID(sid string) (int, bool) {
	raw, ok := strings.CutPrefix(sid, AccessTokenSessionPrefix)
	if !ok {
		return 0, false
	}
	id, err := strconv.Atoi(raw)
	if err != nil || id <= 0 || strconv.Itoa(id) != raw {
		return 0, false
	}
	return id, true
}

// ValidateAccessTokenIdentity checks a token-bound step-up identity without
// taking locks, for use before issuing a verification proof.
func ValidateAccessTokenIdentity(identity AuthSessionIdentity) error {
	tokenID, ok := ParseAccessTokenSessionID(identity.SessionID)
	if !ok {
		return ErrUserSessionInactive
	}
	return checkAccessTokenIdentity(DB, tokenID, identity, false)
}

func validateAccessTokenIdentityWithTx(tx *gorm.DB, tokenID int, identity AuthSessionIdentity) error {
	return checkAccessTokenIdentity(tx, tokenID, identity, true)
}

// checkAccessTokenIdentity locks users before user_access_tokens, matching
// CreateUserAccessToken, and reports every mismatch as an inactive session.
func checkAccessTokenIdentity(tx *gorm.DB, tokenID int, identity AuthSessionIdentity, lock bool) error {
	if identity.UserID <= 0 || identity.UserAuthVersion <= 0 || identity.SessionVersion != AccessTokenSessionVersion {
		return ErrUserSessionInactive
	}
	query := tx
	if lock {
		query = lockForUpdate(tx)
	}
	var user User
	if err := query.Select("id", "status", "auth_version").First(&user, identity.UserID).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return ErrUserSessionInactive
		}
		return err
	}
	if user.Status != common.UserStatusEnabled || user.AuthVersion != identity.UserAuthVersion {
		return ErrUserSessionInactive
	}
	query = tx
	if lock {
		query = lockForUpdate(tx)
	}
	var token UserAccessToken
	if err := query.Where("id = ? AND user_id = ?", tokenID, identity.UserID).First(&token).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return ErrUserSessionInactive
		}
		return err
	}
	if token.Expired(time.Now().Unix()) {
		return ErrUserSessionInactive
	}
	return nil
}

// EnsureLegacyAccessTokenRetireAt records the legacy token deadline on first
// start (now + 30 days) and loads the stored value. The first node to start
// wins; later starts and other nodes only read it.
func EnsureLegacyAccessTokenRetireAt(now int64) error {
	deadline := strconv.FormatInt(now+legacyAccessTokenTransitionDays*24*60*60, 10)
	if err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&Option{Key: legacyAccessTokenRetireAtKey, Value: deadline}).Error; err != nil {
		return err
	}
	var option Option
	if err := DB.Where(&Option{Key: legacyAccessTokenRetireAtKey}).First(&option).Error; err != nil {
		return err
	}
	retireAt, err := strconv.ParseInt(strings.TrimSpace(option.Value), 10, 64)
	if err != nil || retireAt <= 0 {
		return errors.New("invalid LegacyAccessTokenRetireAt option value")
	}
	legacyAccessTokenRetireAt.Store(retireAt)
	return nil
}

func LegacyAccessTokenRetireAt() int64 {
	return legacyAccessTokenRetireAt.Load()
}

func LegacyAccessTokensRetired(now int64) bool {
	retireAt := legacyAccessTokenRetireAt.Load()
	return retireAt <= 0 || now >= retireAt
}