diff --git a/api_token_cli_test.go b/api_token_cli_test.go index 73d88aa05..8d35e1475 100644 --- a/api_token_cli_test.go +++ b/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) { diff --git a/internal/web/service/panel/api_token.go b/internal/web/service/panel/api_token.go index dee120613..190bf23d6 100644 --- a/internal/web/service/panel/api_token.go +++ b/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 } diff --git a/internal/web/service/panel/api_token_test.go b/internal/web/service/panel/api_token_test.go index bcb252244..1135cbca6 100644 --- a/internal/web/service/panel/api_token_test.go +++ b/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) } diff --git a/main.go b/main.go index 3f4df4f56..9545943f4 100644 --- a/main.go +++ b/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)