mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
1230559e69
* security(api): add scoped expiring API tokens * security(api): make scoped token lifecycle enforceable --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
260 lines
7.5 KiB
Go
260 lines
7.5 KiB
Go
package panel
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
|
)
|
|
|
|
type ApiTokenService struct{}
|
|
|
|
const apiTokenLength = 48
|
|
|
|
type ApiTokenView struct {
|
|
Id int `json:"id" example:"2"`
|
|
Name string `json:"name" example:"central-panel-a"`
|
|
Token string `json:"token,omitempty" example:"new-token-string"`
|
|
Enabled bool `json:"enabled" example:"true"`
|
|
CreatedAt int64 `json:"createdAt" example:"1736000000"`
|
|
Scope string `json:"scope" example:"admin"`
|
|
ExpiresAt int64 `json:"expiresAt" example:"0"`
|
|
}
|
|
|
|
func apiTokenCreatedAtSeconds(createdAt int64) int64 {
|
|
if createdAt >= model.ApiTokenUnixMillisecondsThreshold {
|
|
return createdAt / 1000
|
|
}
|
|
return createdAt
|
|
}
|
|
|
|
// toView builds the metadata view returned by List. It never carries the
|
|
// token value: only a SHA-256 hash is stored, and the plaintext is shown
|
|
// exactly once at creation time.
|
|
func toView(t *model.ApiToken) *ApiTokenView {
|
|
return &ApiTokenView{
|
|
Id: t.Id,
|
|
Name: t.Name,
|
|
Enabled: t.Enabled,
|
|
CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt),
|
|
Scope: t.Scope,
|
|
ExpiresAt: t.ExpiresAt,
|
|
}
|
|
}
|
|
|
|
// NormalizeScope validates a requested scope, defaulting empty to admin so
|
|
// callers that omit it keep the legacy full-access behavior.
|
|
func NormalizeScope(scope string) (string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(scope)) {
|
|
case "", model.ApiScopeAdmin:
|
|
return model.ApiScopeAdmin, nil
|
|
case model.ApiScopeMonitor:
|
|
return model.ApiScopeMonitor, nil
|
|
case model.ApiScopeNodeSync:
|
|
return model.ApiScopeNodeSync, nil
|
|
default:
|
|
return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'")
|
|
}
|
|
}
|
|
|
|
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, scope string, expiresAt int64) (*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")
|
|
}
|
|
normScope, err := NormalizeScope(scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if expiresAt < 0 || (expiresAt != 0 && expiresAt <= nowMilli()) {
|
|
return nil, common.NewError("expiresAt must be 0 (never) or a future unix-ms timestamp")
|
|
}
|
|
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")
|
|
}
|
|
plaintext := random.Seq(apiTokenLength)
|
|
row := &model.ApiToken{
|
|
Name: name,
|
|
Token: crypto.HashTokenSHA256(plaintext),
|
|
Enabled: true,
|
|
Scope: normScope,
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
if err := db.Create(row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
view := toView(row)
|
|
view.Token = plaintext
|
|
return view, nil
|
|
}
|
|
|
|
// 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) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, common.NewError("token name is required")
|
|
}
|
|
plaintext := random.Seq(apiTokenLength)
|
|
row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
|
|
if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Create(row).Error
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
view := toView(row)
|
|
view.Token = plaintext
|
|
return view, 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) DeleteExpectedScope(id int, expectedScope string) error {
|
|
if id <= 0 {
|
|
return common.NewError("invalid token id")
|
|
}
|
|
scope, err := requireExpectedScope(expectedScope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return errors.New("token not found with expected scope")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
|
|
if id <= 0 {
|
|
return common.NewError("invalid token id")
|
|
}
|
|
scope, err := requireExpectedScope(expectedScope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return errors.New("token not found with expected scope")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nowMilli() int64 { return time.Now().UnixMilli() }
|
|
|
|
// DisableExpectedScope fails closed unless the stored scope matches the caller,
|
|
// preventing rotation from revoking a newly minted token after a wrong ID.
|
|
func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
|
|
if id <= 0 {
|
|
return common.NewError("invalid token id")
|
|
}
|
|
return s.SetEnabledExpectedScope(id, expectedScope, false)
|
|
}
|
|
|
|
func requireExpectedScope(expectedScope string) (string, error) {
|
|
if strings.TrimSpace(expectedScope) == "" {
|
|
return "", common.NewError("expected scope is required")
|
|
}
|
|
scope, err := NormalizeScope(expectedScope)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return scope, nil
|
|
}
|
|
|
|
// MatchToken returns the enabled, non-expired api_token row whose stored
|
|
// SHA-256 hash matches the presented bearer value, or (nil,false). The loop
|
|
// scans every enabled row with constant-time compares, then applies expiry and
|
|
// scope checks to avoid treating corrupt values as admin.
|
|
func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
|
|
if presented == "" {
|
|
return nil, false
|
|
}
|
|
db := database.GetDB()
|
|
var rows []*model.ApiToken
|
|
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
|
return nil, false
|
|
}
|
|
presentedHash := []byte(crypto.HashTokenSHA256(presented))
|
|
var matched *model.ApiToken
|
|
for _, r := range rows {
|
|
if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
|
|
matched = r
|
|
}
|
|
}
|
|
if matched == nil {
|
|
return nil, false
|
|
}
|
|
if !model.IsKnownApiScope(matched.Scope) {
|
|
return nil, false
|
|
}
|
|
if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
|
|
return nil, false
|
|
}
|
|
return matched, true
|
|
}
|
|
|
|
// Match is the legacy boolean form for callers that do not need scope.
|
|
func (s *ApiTokenService) Match(presented string) bool {
|
|
_, ok := s.MatchToken(presented)
|
|
return ok
|
|
}
|