Files
new-api/model/user_access_token.go
T
CaIon 4924361aef feat(auth): edit the name and permissions of an access token
The token menu now opens an edit dialog instead of a rename prompt.
Renaming still needs no verification. Changing the permissions requires
step-up verification bound to the token id and the new grant, so a
proof cannot be replayed for another token or another set of
permissions; the grant is checked against what the owner can do, the
same as at creation. PATCH /api/user/access_tokens/:id takes an
optional scopes list and is rate limited like the other token routes.

A token that holds permissions the catalog no longer offers can still
be renamed and keeps them; a new grant only contains offered
permissions. Edit stays disabled until the permission catalog has
loaded.

Grant changes are audited as access_token.update with the previous and
new scopes, and audit details show both as permission names.

The permission picker for tokens and admin users now uses toggle
buttons. Token permissions are grouped by personal, administration and
system, each group collapsible with a selected count and select-all or
clear.
2026-09-30 20:52:36 +08:00

330 lines
10 KiB
Go

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("invalid access token scopes: " + 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
}