feat(api-tokens): manage multiple named tokens; add tab/section anchor URLs

Replace the single regenerable API token with a named-token list:
- New ApiToken model + service with constant-time auth matching
- Seeder migrates the legacy `apiToken` setting into a "default" row
- Security tab gets create/enable/delete UI; api-docs page links to it
- Dedicated "API Tokens" section in the in-panel docs

URL anchors now reflect the active tab/section on Settings, Xray, and
API Docs pages, so deep links like `/panel/settings#security` work.

Translations for the 8 new SecurityTab strings added across all locales.
This commit is contained in:
MHSanaei
2026-05-13 16:34:31 +02:00
parent 46b6f8c66c
commit b97ff40ad6
25 changed files with 717 additions and 266 deletions
+119
View File
@@ -0,0 +1,119 @@
package service
import (
"crypto/subtle"
"errors"
"strings"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/random"
)
type ApiTokenService struct{}
const apiTokenLength = 48
type ApiTokenView struct {
Id int `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"createdAt"`
}
func toView(t *model.ApiToken) *ApiTokenView {
return &ApiTokenView{
Id: t.Id,
Name: t.Name,
Token: t.Token,
Enabled: t.Enabled,
CreatedAt: t.CreatedAt,
}
}
func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*ApiTokenView, 0, len(rows))
for _, r := range rows {
out = append(out, toView(r))
}
return out, nil
}
func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, common.NewError("token name is required")
}
if len(name) > 64 {
return nil, common.NewError("token name must be 64 characters or fewer")
}
db := database.GetDB()
var count int64
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewError("a token with that name already exists")
}
row := &model.ApiToken{
Name: name,
Token: random.Seq(apiTokenLength),
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
return nil, err
}
return toView(row), nil
}
func (s *ApiTokenService) Delete(id int) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
}
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("token not found")
}
return nil
}
// Match returns true when the presented bearer token matches any enabled
// row in api_tokens. Uses constant-time compare per row so a remote
// attacker can't time-attack tokens byte-by-byte.
func (s *ApiTokenService) Match(presented string) bool {
if presented == "" {
return false
}
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return false
}
presentedBytes := []byte(presented)
matched := false
for _, r := range rows {
if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
matched = true
}
}
return matched
}
+5 -45
View File
@@ -1,7 +1,6 @@
package service
import (
"crypto/subtle"
_ "embed"
"encoding/json"
"errors"
@@ -211,7 +210,10 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
view.HasApiToken = secretConfigured(mustString(s.getString("apiToken")))
var apiTokenCount int64
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
view.HasApiToken = apiTokenCount > 0
}
view.TgBotToken = ""
view.TwoFactorToken = ""
view.LdapPassword = ""
@@ -467,48 +469,6 @@ func (s *SettingService) GetSecret() ([]byte, error) {
return []byte(secret), err
}
// GetApiToken returns the panel's API token, lazily generating one on
// first read so existing installs upgrade transparently. The token is
// stored plaintext to match how the existing tg/ldap secrets are kept.
func (s *SettingService) GetApiToken() (string, error) {
tok, err := s.getString("apiToken")
if err != nil {
return "", err
}
if tok == "" {
tok = random.Seq(48)
if saveErr := s.saveSetting("apiToken", tok); saveErr != nil {
logger.Warning("save apiToken failed:", saveErr)
return "", saveErr
}
}
return tok, nil
}
// RegenerateApiToken rotates the API token, invalidating any central
// panel that has the old value cached.
func (s *SettingService) RegenerateApiToken() (string, error) {
tok := random.Seq(48)
if err := s.saveSetting("apiToken", tok); err != nil {
return "", err
}
return tok, nil
}
// MatchApiToken returns true when the supplied bearer token matches the
// stored API token. Uses constant-time compare so a remote attacker
// can't time-attack the token byte-by-byte.
func (s *SettingService) MatchApiToken(presented string) bool {
if presented == "" {
return false
}
stored, err := s.getString("apiToken")
if err != nil || stored == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1
}
func (s *SettingService) SetBasePath(basePath string) error {
if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
@@ -877,7 +837,7 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
func (s *SettingService) UpdateSecret(key string, value string) error {
switch key {
case "tgBotToken", "ldapPassword", "twoFactorToken", "apiToken":
case "tgBotToken", "ldapPassword", "twoFactorToken":
return s.saveSetting(key, strings.TrimSpace(value))
default:
return common.NewError("secret key is not replaceable:", key)
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func setupSettingTestDB(t *testing.T) {
@@ -31,7 +32,7 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("apiToken", "api-secret"); err != nil {
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
t.Fatal(err)
}