mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-18 03:45:02 +03:00
a8d0700bf2
* feat(auth): add node-admin API token scope Keep ordinary node-sync credentials unable to self-update a remote panel while allowing explicitly elevated node administrators to inherit synchronization access and invoke updatePanel. * style(docs): format API scope table * fix(auth): preserve mTLS node-sync semantics Allow only an explicit node-admin bearer to elevate a verified mTLS request. Existing admin, monitor, and node-sync bearer values continue to use the historical node-sync identity on mTLS connections. * chore(ci): rerun flaky checks --------- Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
156 lines
5.0 KiB
Go
156 lines
5.0 KiB
Go
package panel
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
"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)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
}
|
|
|
|
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 TestAPITokenNodeAdminScopeRoundTrips(t *testing.T) {
|
|
setupAPITokenTestDB(t)
|
|
svc := &ApiTokenService{}
|
|
|
|
created, err := svc.Create("node-updater", model.ApiScopeNodeAdmin, 0)
|
|
if err != nil {
|
|
t.Fatalf("create node-admin token: %v", err)
|
|
}
|
|
row, ok := svc.MatchToken(created.Token)
|
|
if !ok {
|
|
t.Fatal("fresh node-admin token did not authenticate")
|
|
}
|
|
if created.Scope != model.ApiScopeNodeAdmin || row.Scope != model.ApiScopeNodeAdmin {
|
|
t.Fatalf("created/matched scopes = %q/%q, want node-admin/node-admin", created.Scope, row.Scope)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|