| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- 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
- }
|