Ver código fonte

fix(api-token): keep a token's scope when -getApiToken regenerates it, add -tokenScope (#6700)

* fix(api-token): keep a token's scope when the CLI regenerates it

RecreateByName deleted the named row and created a new one without a Scope,
so the insert took the column default of admin. Since -tokenName lets the CLI
regenerate any token, rotating a monitor or node-sync token silently turned it
into a full-access one.

The replacement now takes the scope of the row it replaces, and a new name
still gets admin as before. A stored scope this build does not know, as after
a downgrade, fails the rotation and leaves the row alone instead of guessing.

Assisted-by: Claude Code:claude-opus-5-5 (mostly)

* feat(cli): let -getApiToken choose the scope of the token it issues

-tokenScope sets the scope on both branches of -getApiToken: the token minted
on a fresh panel and the one regenerated on a populated panel. Without the flag
a regenerated token keeps its scope and a new one gets admin, so every existing
invocation, install.sh included, behaves as before.

An unknown scope is refused before anything is deleted, so a typo cannot
revoke the token it meant to rotate.

Assisted-by: Claude Code:claude-opus-5-5 (mostly)

* fix(api-token): keep a token's expiry when the CLI regenerates it

RecreateByName built the replacement row with ExpiresAt 0, so running
`x-ui setting -getApiToken -tokenName <name>` on a token issued through
the API with a deadline handed back one that never expires, and said
nothing about it - the same silent widening this branch fixed for scope.

The replacement now carries the replaced row's ExpiresAt. A token whose
deadline has already passed is refused instead of rotated, since keeping
the deadline would mint a dead token and dropping it would revive an
expired credential without limit; the expired row is left untouched.

---------

Co-authored-by: Sanaei <[email protected]>
ilyusha 7 horas atrás
pai
commit
05a083eaef

+ 23 - 7
api_token_cli_test.go

@@ -57,12 +57,12 @@ func TestGetApiTokenRotatesOnlyTheNamedToken(t *testing.T) {
 	newTokenCLIEnv(t)
 
 	svc := panel.ApiTokenService{}
-	weekly, err := svc.RecreateByName("weekly-report")
+	weekly, err := svc.RecreateByName("weekly-report", "")
 	if err != nil {
 		t.Fatalf("seed weekly-report: %v", err)
 	}
 
-	GetApiToken(true, "ci-bot")
+	GetApiToken(true, "ci-bot", "")
 
 	names := tokenNames(t)
 	if !hasName(names, "ci-bot") {
@@ -78,7 +78,7 @@ func TestGetApiTokenRotatesOnlyTheNamedToken(t *testing.T) {
 func TestGetApiTokenUsesGivenNameOnEmptyDatabase(t *testing.T) {
 	newTokenCLIEnv(t)
 
-	GetApiToken(true, "ci-bot")
+	GetApiToken(true, "ci-bot", "")
 
 	names := tokenNames(t)
 	if !hasName(names, "ci-bot") {
@@ -89,15 +89,31 @@ func TestGetApiTokenUsesGivenNameOnEmptyDatabase(t *testing.T) {
 	}
 }
 
+// -tokenScope has to reach both branches, or a fresh panel would mint an admin
+// token for a caller that asked for monitor.
+func TestGetApiTokenAppliesGivenScope(t *testing.T) {
+	newTokenCLIEnv(t)
+
+	GetApiToken(true, "ci-bot", model.ApiScopeMonitor)
+	if got := tokenRow(t, "ci-bot").Scope; got != model.ApiScopeMonitor {
+		t.Fatalf("minted scope = %q, want %q", got, model.ApiScopeMonitor)
+	}
+
+	GetApiToken(true, "ci-bot", model.ApiScopeNodeSync)
+	if got := tokenRow(t, "ci-bot").Scope; got != model.ApiScopeNodeSync {
+		t.Fatalf("regenerated scope = %q, want %q", got, model.ApiScopeNodeSync)
+	}
+}
+
 // install.sh records the token it gets on a fresh panel. A later bare
 // -getApiToken must rotate the fallback slot and leave that record valid.
 func TestGetApiTokenPreservesInstallTokenWhenRotating(t *testing.T) {
 	newTokenCLIEnv(t)
 
-	GetApiToken(true, "")
+	GetApiToken(true, "", "")
 	installed := tokenRow(t, installTokenName)
 
-	GetApiToken(true, "")
+	GetApiToken(true, "", "")
 
 	names := tokenNames(t)
 	if !hasName(names, cliFallbackTokenName) {
@@ -134,10 +150,10 @@ func TestGetApiTokenWarnsOnIgnoredPositionalArgs(t *testing.T) {
 func TestGetApiTokenTrimsName(t *testing.T) {
 	newTokenCLIEnv(t)
 
-	if _, err := (&panel.ApiTokenService{}).RecreateByName("seed"); err != nil {
+	if _, err := (&panel.ApiTokenService{}).RecreateByName("seed", ""); err != nil {
 		t.Fatalf("seed: %v", err)
 	}
-	GetApiToken(true, "   ")
+	GetApiToken(true, "   ", "")
 
 	names := tokenNames(t)
 	if !hasName(names, cliFallbackTokenName) {

+ 32 - 2
internal/web/service/panel/api_token.go

@@ -119,7 +119,7 @@ func (s *ApiTokenService) Create(name, scope string, expiresAt int64) (*ApiToken
 
 // 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) {
+func (s *ApiTokenService) RecreateByName(name, scope string) (*ApiTokenView, error) {
 	name = strings.TrimSpace(name)
 	if name == "" {
 		return nil, common.NewError("token name is required")
@@ -128,9 +128,39 @@ func (s *ApiTokenService) RecreateByName(name string) (*ApiTokenView, error) {
 	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}
+	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
 		}

+ 168 - 6
internal/web/service/panel/api_token_test.go

@@ -40,7 +40,7 @@ func TestRecreateByNamePreservesTokenWhenReplacementFails(t *testing.T) {
 	dbtest.InitDB(t, config.GetDBPath())
 
 	svc := ApiTokenService{}
-	first, err := svc.RecreateByName("cli-fallback")
+	first, err := svc.RecreateByName("cli-fallback", "")
 	if err != nil {
 		t.Fatalf("first recreate: %v", err)
 	}
@@ -55,7 +55,7 @@ func TestRecreateByNamePreservesTokenWhenReplacementFails(t *testing.T) {
 	}
 	t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
 
-	if _, err := svc.RecreateByName("cli-fallback"); !errors.Is(err, errInjectedTokenCreate) {
+	if _, err := svc.RecreateByName("cli-fallback", ""); !errors.Is(err, errInjectedTokenCreate) {
 		t.Fatalf("recreate error = %v, want %v", err, errInjectedTokenCreate)
 	}
 	var row model.ApiToken
@@ -76,28 +76,190 @@ func TestRecreateByNameRejectsOverlongName(t *testing.T) {
 	const wantErr = "token name must be 64 characters or fewer"
 
 	svc := ApiTokenService{}
-	_, err := svc.RecreateByName(strings.Repeat("n", 65))
+	_, err := svc.RecreateByName(strings.Repeat("n", 65), "")
 	if err == nil {
 		t.Fatal("expected a 65-character token name to be rejected")
 	}
 	if got := strings.TrimSpace(err.Error()); got != wantErr {
 		t.Fatalf("error = %q, want %q — any other error would pass a bare nil check", got, wantErr)
 	}
-	if _, err := svc.RecreateByName(strings.Repeat("n", 64)); err != nil {
+	if _, err := svc.RecreateByName(strings.Repeat("n", 64), ""); err != nil {
 		t.Fatalf("64 characters is the documented limit, got: %v", err)
 	}
 }
 
+// Rotating a monitor token through the CLI silently reissued it as admin,
+// because the replacement row took the column default instead of the old scope.
+func TestRecreateByNameKeepsReplacedTokenScope(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	dbtest.InitDB(t, config.GetDBPath())
+
+	svc := ApiTokenService{}
+	if _, err := svc.Create("grafana", model.ApiScopeMonitor, 0); err != nil {
+		t.Fatalf("seed grafana: %v", err)
+	}
+	rotated, err := svc.RecreateByName("grafana", "")
+	if err != nil {
+		t.Fatalf("recreate: %v", err)
+	}
+
+	var row model.ApiToken
+	if err := database.GetDB().Where("name = ?", "grafana").First(&row).Error; err != nil {
+		t.Fatalf("load grafana: %v", err)
+	}
+	if row.Scope != model.ApiScopeMonitor {
+		t.Fatalf("stored scope = %q, want %q", row.Scope, model.ApiScopeMonitor)
+	}
+	if rotated.Scope != model.ApiScopeMonitor {
+		t.Fatalf("returned scope = %q, want %q", rotated.Scope, model.ApiScopeMonitor)
+	}
+}
+
+// An explicit scope wins over the replaced token's, and a bad one is refused
+// before the old token is touched.
+func TestRecreateByNameAppliesGivenScope(t *testing.T) {
+	tests := []struct {
+		name      string
+		seedScope string
+		scope     string
+		want      string
+		wantErr   string
+	}{
+		{name: "replaces a monitor token as node-sync", seedScope: model.ApiScopeMonitor, scope: model.ApiScopeNodeSync, want: model.ApiScopeNodeSync},
+		{name: "creates a new token as monitor", scope: model.ApiScopeMonitor, want: model.ApiScopeMonitor},
+		{name: "refuses an unknown scope", seedScope: model.ApiScopeMonitor, scope: "root", want: model.ApiScopeMonitor, wantErr: "scope must be 'admin', 'monitor', or 'node-sync'"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Setenv("XUI_DB_FOLDER", t.TempDir())
+			dbtest.InitDB(t, config.GetDBPath())
+
+			svc := ApiTokenService{}
+			var seeded *ApiTokenView
+			if tt.seedScope != "" {
+				var err error
+				if seeded, err = svc.Create("bot", tt.seedScope, 0); err != nil {
+					t.Fatalf("seed bot: %v", err)
+				}
+			}
+			_, err := svc.RecreateByName("bot", tt.scope)
+			if tt.wantErr != "" {
+				if err == nil || strings.TrimSpace(err.Error()) != tt.wantErr {
+					t.Fatalf("error = %v, want %q", err, tt.wantErr)
+				}
+				if !svc.Match(seeded.Token) {
+					t.Fatal("the old token was revoked by a refused rotation")
+				}
+			} else if err != nil {
+				t.Fatalf("recreate: %v", err)
+			}
+
+			var row model.ApiToken
+			if err := database.GetDB().Where("name = ?", "bot").First(&row).Error; err != nil {
+				t.Fatalf("load bot: %v", err)
+			}
+			if row.Scope != tt.want {
+				t.Fatalf("stored scope = %q, want %q", row.Scope, tt.want)
+			}
+		})
+	}
+}
+
+// A scope this build does not know, as after a downgrade, must not be guessed
+// as admin; the rotation fails and the stored row stays untouched.
+func TestRecreateByNameRefusesUnknownStoredScope(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	dbtest.InitDB(t, config.GetDBPath())
+
+	db := database.GetDB()
+	stored := model.ApiToken{Name: "remote", Token: "stored-hash", Enabled: true, Scope: "node-admin"}
+	if err := db.Create(&stored).Error; err != nil {
+		t.Fatalf("seed remote: %v", err)
+	}
+
+	const wantErr = `token "remote" has unknown scope "node-admin"`
+	_, err := (&ApiTokenService{}).RecreateByName("remote", "")
+	if err == nil || strings.TrimSpace(err.Error()) != wantErr {
+		t.Fatalf("error = %v, want %q", err, wantErr)
+	}
+	var row model.ApiToken
+	if err := db.Where("name = ?", "remote").First(&row).Error; err != nil {
+		t.Fatalf("load remote: %v", err)
+	}
+	if row.Id != stored.Id || row.Token != stored.Token || row.Scope != stored.Scope {
+		t.Fatalf("row = %+v, want the stored row %+v unchanged", row, stored)
+	}
+}
+
+// Rotating a token issued with an expiry through the API handed back one that
+// never expires, since the replacement row took ExpiresAt 0.
+func TestRecreateByNameKeepsReplacedTokenExpiry(t *testing.T) {
+	for _, scope := range []string{"", model.ApiScopeNodeSync} {
+		t.Run("scope="+scope, func(t *testing.T) {
+			t.Setenv("XUI_DB_FOLDER", t.TempDir())
+			dbtest.InitDB(t, config.GetDBPath())
+
+			svc := ApiTokenService{}
+			expiresAt := nowMilli() + 30*24*60*60*1000
+			if _, err := svc.Create("grafana", model.ApiScopeMonitor, expiresAt); err != nil {
+				t.Fatalf("seed grafana: %v", err)
+			}
+			rotated, err := svc.RecreateByName("grafana", scope)
+			if err != nil {
+				t.Fatalf("recreate: %v", err)
+			}
+
+			var row model.ApiToken
+			if err := database.GetDB().Where("name = ?", "grafana").First(&row).Error; err != nil {
+				t.Fatalf("load grafana: %v", err)
+			}
+			if row.ExpiresAt != expiresAt || rotated.ExpiresAt != expiresAt {
+				t.Fatalf("stored expiresAt = %d, returned %d, want %d", row.ExpiresAt, rotated.ExpiresAt, expiresAt)
+			}
+		})
+	}
+}
+
+// An expired token must not come back to life without an expiry; the rotation
+// is refused and the expired row is left as it was.
+func TestRecreateByNameRefusesExpiredToken(t *testing.T) {
+	for _, scope := range []string{"", model.ApiScopeAdmin} {
+		t.Run("scope="+scope, func(t *testing.T) {
+			t.Setenv("XUI_DB_FOLDER", t.TempDir())
+			dbtest.InitDB(t, config.GetDBPath())
+
+			db := database.GetDB()
+			stored := model.ApiToken{Name: "grafana", Token: "stored-hash", Enabled: true, Scope: model.ApiScopeMonitor, ExpiresAt: nowMilli() - 1000}
+			if err := db.Create(&stored).Error; err != nil {
+				t.Fatalf("seed grafana: %v", err)
+			}
+
+			const wantErr = `token "grafana" has expired; create a new token from the panel or the API instead`
+			_, err := (&ApiTokenService{}).RecreateByName("grafana", scope)
+			if err == nil || strings.TrimSpace(err.Error()) != wantErr {
+				t.Fatalf("error = %v, want %q", err, wantErr)
+			}
+			var row model.ApiToken
+			if err := db.Where("name = ?", "grafana").First(&row).Error; err != nil {
+				t.Fatalf("load grafana: %v", err)
+			}
+			if row.Id != stored.Id || row.Token != stored.Token || row.ExpiresAt != stored.ExpiresAt {
+				t.Fatalf("row = %+v, want the stored row %+v unchanged", row, stored)
+			}
+		})
+	}
+}
+
 func TestRecreateByNameKeepsOneToken(t *testing.T) {
 	t.Setenv("XUI_DB_FOLDER", t.TempDir())
 	dbtest.InitDB(t, config.GetDBPath())
 
 	svc := ApiTokenService{}
-	first, err := svc.RecreateByName("cli-fallback")
+	first, err := svc.RecreateByName("cli-fallback", "")
 	if err != nil {
 		t.Fatalf("first recreate: %v", err)
 	}
-	second, err := svc.RecreateByName("cli-fallback")
+	second, err := svc.RecreateByName("cli-fallback", "")
 	if err != nil {
 		t.Fatalf("second recreate: %v", err)
 	}

+ 6 - 4
main.go

@@ -498,7 +498,7 @@ func GetListenIP(getListen bool) {
 	}
 }
 
-func GetApiToken(getApiToken bool, tokenName string) {
+func GetApiToken(getApiToken bool, tokenName, tokenScope string) {
 	if !getApiToken {
 		return
 	}
@@ -526,7 +526,7 @@ func GetApiToken(getApiToken bool, tokenName string) {
 		if rotated == "" {
 			rotated = cliFallbackTokenName
 		}
-		created, err := apiTokenService.RecreateByName(rotated)
+		created, err := apiTokenService.RecreateByName(rotated, tokenScope)
 		if err != nil {
 			fmt.Println("Failed to create a fallback API token:", err)
 			return
@@ -538,7 +538,7 @@ func GetApiToken(getApiToken bool, tokenName string) {
 	if name == "" {
 		name = installTokenName
 	}
-	created, err := apiTokenService.Create(name, "", 0)
+	created, err := apiTokenService.Create(name, tokenScope, 0)
 	if err != nil {
 		fmt.Println("create apiToken failed, error info:", err)
 		return
@@ -621,6 +621,7 @@ func main() {
 	var getCert bool
 	var getApiToken bool
 	var tokenName string
+	var tokenScope string
 	var resetTwoFactor bool
 	settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
 	settingCmd.BoolVar(&show, "show", false, "Display current settings")
@@ -634,6 +635,7 @@ func main() {
 	settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
 	settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Print an API token for CLI use, regenerating it and invalidating the previous one; on a panel with no tokens yet it mints one instead")
 	settingCmd.StringVar(&tokenName, "tokenName", "", "Name of the token -getApiToken acts on (default: "+cliFallbackTokenName+", or "+installTokenName+" on a panel with no tokens)")
+	settingCmd.StringVar(&tokenScope, "tokenScope", "", "Scope of the token -getApiToken issues: admin, monitor or node-sync (default: the scope of the token it replaces, or admin for a new one)")
 	settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
 	settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
 	settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
@@ -732,7 +734,7 @@ func main() {
 			GetCertificate(getCert)
 		}
 		if getApiToken {
-			GetApiToken(getApiToken, tokenName)
+			GetApiToken(getApiToken, tokenName, tokenScope)
 		}
 		if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
 			updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)