mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 00:01:02 +00:00
feat(api): scoped, optionally expiring API tokens (#6201)
* security(api): add scoped expiring API tokens * security(api): make scoped token lifecycle enforceable --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
@@ -48,3 +48,34 @@ func TestNormalizeApiTokenCreatedAtSeconds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateApiTokenScopeAndExpiryFromLegacyTable(t *testing.T) {
|
||||
originalDB := db
|
||||
t.Cleanup(func() { db = originalDB })
|
||||
var err error
|
||||
db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE api_tokens (
|
||||
id integer primary key autoincrement, name text, token text, enabled numeric, created_at integer
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create legacy table: %v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO api_tokens(name, token, enabled, created_at) VALUES ('legacy','hash',1,1)").Error; err != nil {
|
||||
t.Fatalf("seed legacy row: %v", err)
|
||||
}
|
||||
if err := migrateApiTokenScopeAndExpiry(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := migrateApiTokenScopeAndExpiry(); err != nil {
|
||||
t.Fatalf("idempotent migrate: %v", err)
|
||||
}
|
||||
var row model.ApiToken
|
||||
if err := db.First(&row).Error; err != nil {
|
||||
t.Fatalf("read migrated row: %v", err)
|
||||
}
|
||||
if row.Scope != model.ApiScopeAdmin || row.ExpiresAt != 0 {
|
||||
t.Fatalf("legacy defaults = %q/%d, want admin/0", row.Scope, row.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ func initModels() error {
|
||||
if err := normalizeApiTokenCreatedAtSeconds(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateApiTokenScopeAndExpiry(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dropLegacyForeignKeys(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2075,6 +2078,22 @@ func normalizeApiTokenCreatedAtSeconds() error {
|
||||
UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
|
||||
}
|
||||
|
||||
func migrateApiTokenScopeAndExpiry() error {
|
||||
m := db.Migrator()
|
||||
if !m.HasColumn(&model.ApiToken{}, "Scope") {
|
||||
if err := m.AddColumn(&model.ApiToken{}, "Scope"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&model.ApiToken{}, "ExpiresAt") {
|
||||
if err := m.AddColumn(&model.ApiToken{}, "ExpiresAt"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Model(&model.ApiToken{}).Where("scope IS NULL OR TRIM(scope) = ''").
|
||||
Updates(map[string]any{"scope": model.ApiScopeAdmin, "expires_at": 0}).Error
|
||||
}
|
||||
|
||||
// openPostgresWithRetry retries the initial PostgreSQL connection with
|
||||
// backoff so a database that starts slower than the panel (or drops out
|
||||
// briefly) does not immediately kill the process and trip systemd's
|
||||
|
||||
@@ -154,12 +154,24 @@ type HistoryOfSeeders struct {
|
||||
// from the seconds-based API token timestamp contract.
|
||||
const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000
|
||||
|
||||
const (
|
||||
ApiScopeAdmin = "admin"
|
||||
ApiScopeMonitor = "monitor"
|
||||
ApiScopeNodeSync = "node-sync"
|
||||
)
|
||||
|
||||
func IsKnownApiScope(s string) bool {
|
||||
return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
Token string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||
Scope string `json:"scope" gorm:"not null;default:admin"`
|
||||
ExpiresAt int64 `json:"expiresAt" gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
|
||||
|
||||
Reference in New Issue
Block a user