Files
3x-ui/internal/web/service/panel/api_token.go
T
25f2002654 f6a233220a perf(auth): look up api tokens by their hash index (#6915)
Every bearer-authenticated request, including each node-sync call, loaded all
enabled api_tokens rows and compared each stored SHA-256 hash in constant time.
Tokens are stored hashed, so the database can compare hashes directly: a
timing leak there reveals only a stored hash, which cannot authenticate. The
lookup is now one indexed seek on idx_api_tokens_token, which AutoMigrate
creates from the model tag on new and existing databases; scope and expiry
are still checked afterwards.
2026-10-24 05:06:49 +02:00

285 lines
8.5 KiB
Go

package panel
import (
"errors"
"strings"
"time"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
)
type ApiTokenService struct{}
const apiTokenLength = 48
type ApiTokenView struct {
Id int `json:"id" example:"2"`
Name string `json:"name" example:"central-panel-a"`
Token string `json:"token,omitempty" example:"new-token-string"`
Enabled bool `json:"enabled" example:"true"`
CreatedAt int64 `json:"createdAt" example:"1736000000"`
Scope string `json:"scope" example:"admin"`
ExpiresAt int64 `json:"expiresAt" example:"0"`
}
func apiTokenCreatedAtSeconds(createdAt int64) int64 {
if createdAt >= model.ApiTokenUnixMillisecondsThreshold {
return createdAt / 1000
}
return createdAt
}
// toView builds the metadata view returned by List. It never carries the
// token value: only a SHA-256 hash is stored, and the plaintext is shown
// exactly once at creation time.
func toView(t *model.ApiToken) *ApiTokenView {
return &ApiTokenView{
Id: t.Id,
Name: t.Name,
Enabled: t.Enabled,
CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt),
Scope: t.Scope,
ExpiresAt: t.ExpiresAt,
}
}
// NormalizeScope validates a requested scope, defaulting empty to admin so
// callers that omit it keep the legacy full-access behavior.
func NormalizeScope(scope string) (string, error) {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "", model.ApiScopeAdmin:
return model.ApiScopeAdmin, nil
case model.ApiScopeMonitor:
return model.ApiScopeMonitor, nil
case model.ApiScopeNodeSync:
return model.ApiScopeNodeSync, nil
case model.ApiScopeNodeAdmin:
return model.ApiScopeNodeAdmin, nil
default:
return "", common.NewError("scope must be 'admin', 'monitor', 'node-sync', or 'node-admin'")
}
}
func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*ApiTokenView, 0, len(rows))
for _, r := range rows {
out = append(out, toView(r))
}
return out, nil
}
func (s *ApiTokenService) Create(name, scope string, expiresAt int64) (*ApiTokenView, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, common.NewError("token name is required")
}
if len(name) > 64 {
return nil, common.NewError("token name must be 64 characters or fewer")
}
normScope, err := NormalizeScope(scope)
if err != nil {
return nil, err
}
if expiresAt < 0 || (expiresAt != 0 && expiresAt <= nowMilli()) {
return nil, common.NewError("expiresAt must be 0 (never) or a future unix-ms timestamp")
}
db := database.GetDB()
var count int64
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewError("a token with that name already exists")
}
plaintext := random.Seq(apiTokenLength)
row := &model.ApiToken{
Name: name,
Token: crypto.HashTokenSHA256(plaintext),
Enabled: true,
Scope: normScope,
ExpiresAt: expiresAt,
}
if err := db.Create(row).Error; err != nil {
return nil, err
}
view := toView(row)
view.Token = plaintext
return view, nil
}
// RecreateByName replaces any token with this name, keeping exactly one so a
// repeatedly-run caller cannot accumulate credentials it can never revoke.
func (s *ApiTokenService) RecreateByName(name, scope string) (*ApiTokenView, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, common.NewError("token name is required")
}
// Same column, same limit as Create: the CLI now feeds this operator input.
if len(name) > 64 {
return nil, common.NewError("token name must be 64 characters or fewer")
}
givenScope := ""
if strings.TrimSpace(scope) != "" {
var err error
if givenScope, err = NormalizeScope(scope); err != nil {
return nil, err
}
}
plaintext := random.Seq(apiTokenLength)
row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true, Scope: givenScope}
if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
var replaced []model.ApiToken
if err := tx.Where("name = ?", name).Order("id asc").Limit(1).Find(&replaced).Error; err != nil {
return err
}
if len(replaced) > 0 {
// A rotation keeps the deadline the token was issued with; reviving an
// expired one would silently hand back a credential that never expires.
if replaced[0].ExpiresAt != 0 && nowMilli() >= replaced[0].ExpiresAt {
return common.NewErrorf("token %q has expired; create a new token from the panel or the API instead", name)
}
row.ExpiresAt = replaced[0].ExpiresAt
}
if row.Scope == "" {
// An empty Scope takes the column default of admin, so a rotated
// monitor or node-sync token would silently gain full access.
row.Scope = model.ApiScopeAdmin
if len(replaced) > 0 {
if !model.IsKnownApiScope(replaced[0].Scope) {
return common.NewErrorf("token %q has unknown scope %q", name, replaced[0].Scope)
}
row.Scope = replaced[0].Scope
}
}
if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
return err
}
return tx.Create(row).Error
}); err != nil {
return nil, err
}
view := toView(row)
view.Token = plaintext
return view, nil
}
func (s *ApiTokenService) Delete(id int) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
}
func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error {
if id <= 0 {
return common.NewError("invalid token id")
}
scope, err := requireExpectedScope(expectedScope)
if err != nil {
return err
}
res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("token not found with expected scope")
}
return nil
}
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("token not found")
}
return nil
}
func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
if id <= 0 {
return common.NewError("invalid token id")
}
scope, err := requireExpectedScope(expectedScope)
if err != nil {
return err
}
res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("token not found with expected scope")
}
return nil
}
func nowMilli() int64 { return time.Now().UnixMilli() }
// DisableExpectedScope fails closed unless the stored scope matches the caller,
// preventing rotation from revoking a newly minted token after a wrong ID.
func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
if id <= 0 {
return common.NewError("invalid token id")
}
return s.SetEnabledExpectedScope(id, expectedScope, false)
}
func requireExpectedScope(expectedScope string) (string, error) {
if strings.TrimSpace(expectedScope) == "" {
return "", common.NewError("expected scope is required")
}
scope, err := NormalizeScope(expectedScope)
if err != nil {
return "", err
}
return scope, nil
}
// MatchToken seeks the enabled row by the presented value's SHA-256 (comparing hashes leaks nothing
// usable), then checks scope and expiry so a corrupt value is never treated as admin.
func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
if presented == "" {
return nil, false
}
db := database.GetDB()
var row model.ApiToken
if err := db.Model(model.ApiToken{}).
Where("token = ? AND enabled = ?", crypto.HashTokenSHA256(presented), true).
First(&row).Error; err != nil {
return nil, false
}
if !model.IsKnownApiScope(row.Scope) {
return nil, false
}
if row.ExpiresAt != 0 && nowMilli() >= row.ExpiresAt {
return nil, false
}
return &row, true
}
// Match is the legacy boolean form for callers that do not need scope.
func (s *ApiTokenService) Match(presented string) bool {
_, ok := s.MatchToken(presented)
return ok
}