api_token.go 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. package panel
  2. import (
  3. "crypto/subtle"
  4. "errors"
  5. "strings"
  6. "gorm.io/gorm"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  9. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  10. "github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
  11. "github.com/mhsanaei/3x-ui/v3/internal/util/random"
  12. )
  13. type ApiTokenService struct{}
  14. const apiTokenLength = 48
  15. type ApiTokenView struct {
  16. Id int `json:"id" example:"2"`
  17. Name string `json:"name" example:"central-panel-a"`
  18. Token string `json:"token,omitempty" example:"new-token-string"`
  19. Enabled bool `json:"enabled" example:"true"`
  20. CreatedAt int64 `json:"createdAt" example:"1736000000"`
  21. }
  22. func apiTokenCreatedAtSeconds(createdAt int64) int64 {
  23. if createdAt >= model.ApiTokenUnixMillisecondsThreshold {
  24. return createdAt / 1000
  25. }
  26. return createdAt
  27. }
  28. // toView builds the metadata view returned by List. It never carries the
  29. // token value: only a SHA-256 hash is stored, and the plaintext is shown
  30. // exactly once at creation time.
  31. func toView(t *model.ApiToken) *ApiTokenView {
  32. return &ApiTokenView{
  33. Id: t.Id,
  34. Name: t.Name,
  35. Enabled: t.Enabled,
  36. CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt),
  37. }
  38. }
  39. func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
  40. db := database.GetDB()
  41. var rows []*model.ApiToken
  42. if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
  43. return nil, err
  44. }
  45. out := make([]*ApiTokenView, 0, len(rows))
  46. for _, r := range rows {
  47. out = append(out, toView(r))
  48. }
  49. return out, nil
  50. }
  51. func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
  52. name = strings.TrimSpace(name)
  53. if name == "" {
  54. return nil, common.NewError("token name is required")
  55. }
  56. if len(name) > 64 {
  57. return nil, common.NewError("token name must be 64 characters or fewer")
  58. }
  59. db := database.GetDB()
  60. var count int64
  61. if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
  62. return nil, err
  63. }
  64. if count > 0 {
  65. return nil, common.NewError("a token with that name already exists")
  66. }
  67. plaintext := random.Seq(apiTokenLength)
  68. row := &model.ApiToken{
  69. Name: name,
  70. Token: crypto.HashTokenSHA256(plaintext),
  71. Enabled: true,
  72. }
  73. if err := db.Create(row).Error; err != nil {
  74. return nil, err
  75. }
  76. view := toView(row)
  77. view.Token = plaintext
  78. return view, nil
  79. }
  80. // RecreateByName replaces any token with this name, keeping exactly one so a
  81. // repeatedly-run caller cannot accumulate credentials it can never revoke.
  82. func (s *ApiTokenService) RecreateByName(name string) (*ApiTokenView, error) {
  83. name = strings.TrimSpace(name)
  84. if name == "" {
  85. return nil, common.NewError("token name is required")
  86. }
  87. plaintext := random.Seq(apiTokenLength)
  88. row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
  89. if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
  90. if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
  91. return err
  92. }
  93. return tx.Create(row).Error
  94. }); err != nil {
  95. return nil, err
  96. }
  97. view := toView(row)
  98. view.Token = plaintext
  99. return view, nil
  100. }
  101. func (s *ApiTokenService) Delete(id int) error {
  102. if id <= 0 {
  103. return common.NewError("invalid token id")
  104. }
  105. db := database.GetDB()
  106. return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
  107. }
  108. func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
  109. if id <= 0 {
  110. return common.NewError("invalid token id")
  111. }
  112. db := database.GetDB()
  113. res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
  114. if res.Error != nil {
  115. return res.Error
  116. }
  117. if res.RowsAffected == 0 {
  118. return errors.New("token not found")
  119. }
  120. return nil
  121. }
  122. // Match returns true when the presented bearer token matches any enabled
  123. // row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented
  124. // value is hashed before a constant-time compare per row keeps a remote
  125. // attacker from timing the comparison byte-by-byte.
  126. func (s *ApiTokenService) Match(presented string) bool {
  127. if presented == "" {
  128. return false
  129. }
  130. db := database.GetDB()
  131. var rows []*model.ApiToken
  132. if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
  133. return false
  134. }
  135. presentedHash := []byte(crypto.HashTokenSHA256(presented))
  136. matched := false
  137. for _, r := range rows {
  138. if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
  139. matched = true
  140. }
  141. }
  142. return matched
  143. }