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>
This commit is contained in:
n0ctal
2026-08-15 18:31:49 +05:00
committed by GitHub
parent aecbad3ab1
commit 1230559e69
19 changed files with 785 additions and 94 deletions
+116 -14
View File
@@ -4,6 +4,7 @@ import (
"crypto/subtle"
"errors"
"strings"
"time"
"gorm.io/gorm"
@@ -24,6 +25,8 @@ type ApiTokenView struct {
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 {
@@ -42,6 +45,23 @@ func toView(t *model.ApiToken) *ApiTokenView {
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'")
}
}
@@ -58,7 +78,7 @@ func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
return out, nil
}
func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
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")
@@ -66,6 +86,13 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
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 {
@@ -76,9 +103,11 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
}
plaintext := random.Seq(apiTokenLength)
row := &model.ApiToken{
Name: name,
Token: crypto.HashTokenSHA256(plaintext),
Enabled: true,
Name: name,
Token: crypto.HashTokenSHA256(plaintext),
Enabled: true,
Scope: normScope,
ExpiresAt: expiresAt,
}
if err := db.Create(row).Error; err != nil {
return nil, err
@@ -118,6 +147,24 @@ func (s *ApiTokenService) Delete(id int) error {
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")
@@ -133,25 +180,80 @@ func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
return nil
}
// Match returns true when the presented bearer token matches any enabled
// row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented
// value is hashed before a constant-time compare per row keeps a remote
// attacker from timing the comparison byte-by-byte.
func (s *ApiTokenService) Match(presented string) bool {
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 false
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 false
return nil, false
}
presentedHash := []byte(crypto.HashTokenSHA256(presented))
matched := false
var matched *model.ApiToken
for _, r := range rows {
if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
matched = true
matched = r
}
}
return matched
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
}
@@ -0,0 +1,144 @@
package panel
import (
"path/filepath"
"testing"
"time"
"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/crypto"
)
func setupAPITokenTestDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() {
if err := database.CloseDB(); err != nil {
t.Fatalf("CloseDB: %v", err)
}
})
}
func TestAPITokenScopeExpiryAndExpectedRevoke(t *testing.T) {
setupAPITokenTestDB(t)
svc := &ApiTokenService{}
future := time.Now().Add(time.Hour).UnixMilli()
created, err := svc.Create("node-1", model.ApiScopeNodeSync, future)
if err != nil {
t.Fatalf("create node-sync token: %v", err)
}
row, ok := svc.MatchToken(created.Token)
if !ok {
t.Fatal("fresh node-sync token did not match")
}
if row.Scope != model.ApiScopeNodeSync || row.ExpiresAt != future {
t.Fatalf("matched row scope/expiry = %s/%d, want node-sync/%d", row.Scope, row.ExpiresAt, future)
}
if _, err := svc.Create("bad-scope", "superuser", 0); err == nil {
t.Fatal("unknown scope must be rejected on create")
}
if _, err := svc.Create("past", model.ApiScopeAdmin, time.Now().Add(-time.Minute).UnixMilli()); err == nil {
t.Fatal("past expiry must be rejected on create")
}
const expiredPlain = "expired-token"
if err := database.GetDB().Create(&model.ApiToken{
Name: "expired",
Token: crypto.HashTokenSHA256(expiredPlain),
Enabled: true,
Scope: model.ApiScopeAdmin,
ExpiresAt: time.Now().Add(-time.Minute).UnixMilli(),
}).Error; err != nil {
t.Fatalf("seed expired token: %v", err)
}
if _, ok := svc.MatchToken(expiredPlain); ok {
t.Fatal("expired token must fail closed")
}
const unknownPlain = "unknown-scope-token"
if err := database.GetDB().Create(&model.ApiToken{
Name: "unknown",
Token: crypto.HashTokenSHA256(unknownPlain),
Enabled: true,
Scope: "root",
}).Error; err != nil {
t.Fatalf("seed unknown-scope token: %v", err)
}
if _, ok := svc.MatchToken(unknownPlain); ok {
t.Fatal("unknown token scope must fail closed")
}
if err := svc.DisableExpectedScope(created.Id, model.ApiScopeAdmin); err == nil {
t.Fatal("expected-scope revoke must refuse a node-sync token when admin was expected")
}
if _, ok := svc.MatchToken(created.Token); !ok {
t.Fatal("wrong expected-scope revoke disabled the token")
}
if err := svc.DisableExpectedScope(created.Id, model.ApiScopeNodeSync); err != nil {
t.Fatalf("disable expected node-sync token: %v", err)
}
if _, ok := svc.MatchToken(created.Token); ok {
t.Fatal("disabled token still matched")
}
}
func TestAPITokenDeleteAndEnableRequireExpectedScope(t *testing.T) {
setupAPITokenTestDB(t)
svc := &ApiTokenService{}
created, err := svc.Create("scoped", model.ApiScopeMonitor, 0)
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := svc.SetEnabledExpectedScope(created.Id, model.ApiScopeAdmin, false); err == nil {
t.Fatal("wrong expected scope changed token state")
}
if _, ok := svc.MatchToken(created.Token); !ok {
t.Fatal("wrong-scope update disabled token")
}
if err := svc.DeleteExpectedScope(created.Id, model.ApiScopeAdmin); err == nil {
t.Fatal("wrong expected scope deleted token")
}
if err := svc.DeleteExpectedScope(created.Id, model.ApiScopeMonitor); err != nil {
t.Fatalf("DeleteExpectedScope: %v", err)
}
}
func TestAPITokenEmptyExpectedScopeCannotTargetAdmin(t *testing.T) {
setupAPITokenTestDB(t)
svc := &ApiTokenService{}
created, err := svc.Create("admin-token", model.ApiScopeAdmin, 0)
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := svc.DisableExpectedScope(created.Id, ""); err == nil {
t.Fatal("empty expected scope defaulted to admin")
}
if _, ok := svc.MatchToken(created.Token); !ok {
t.Fatal("empty expected scope disabled the admin token")
}
}
func TestAPITokenAdditiveDefaultsPreserveLegacyAccess(t *testing.T) {
setupAPITokenTestDB(t)
const plaintext = "legacy-token"
if err := database.GetDB().Exec(
"INSERT INTO api_tokens (name, token, enabled, created_at) VALUES (?, ?, ?, ?)",
"legacy", crypto.HashTokenSHA256(plaintext), true, time.Now().Unix(),
).Error; err != nil {
t.Fatalf("insert legacy-shaped token: %v", err)
}
row, ok := (&ApiTokenService{}).MatchToken(plaintext)
if !ok {
t.Fatal("legacy-shaped token no longer authenticates")
}
if row.Scope != model.ApiScopeAdmin || row.ExpiresAt != 0 {
t.Fatalf("legacy defaults = scope %q expiry %d, want admin/0", row.Scope, row.ExpiresAt)
}
}