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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
ilyusha
2026-10-02 20:05:27 +03:00
committed by GitHub
parent 721de5adde
commit 05a083eaef
4 changed files with 229 additions and 19 deletions
+32 -2
View File
@@ -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
}