Files
3x-ui/internal/web/service/panel/api_token.go
T
n0ctal 1230559e69 feat(api): scoped, optionally expiring API tokens (#6201)
* security(api): add scoped expiring API tokens

* security(api): make scoped token lifecycle enforceable

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-15 15:31:49 +02:00

260 lines
7.5 KiB
Go

package panel
import (
"crypto/subtle"
"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
default:
return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'")
}
}
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 string) (*ApiTokenView, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, common.NewError("token name is required")
}
plaintext := random.Seq(apiTokenLength)
row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
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 returns the enabled, non-expired api_token row whose stored
// SHA-256 hash matches the presented bearer value, or (nil,false). The loop
// scans every enabled row with constant-time compares, then applies expiry and
// scope checks to avoid treating corrupt values as admin.
func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
if presented == "" {
return nil, false
}
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return nil, false
}
presentedHash := []byte(crypto.HashTokenSHA256(presented))
var matched *model.ApiToken
for _, r := range rows {
if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
matched = r
}
}
if matched == nil {
return nil, false
}
if !model.IsKnownApiScope(matched.Scope) {
return nil, false
}
if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
return nil, false
}
return matched, 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
}