api_token.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. package panel
  2. import (
  3. "crypto/subtle"
  4. "errors"
  5. "strings"
  6. "time"
  7. "gorm.io/gorm"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  10. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  11. "github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
  12. "github.com/mhsanaei/3x-ui/v3/internal/util/random"
  13. )
  14. type ApiTokenService struct{}
  15. const apiTokenLength = 48
  16. type ApiTokenView struct {
  17. Id int `json:"id" example:"2"`
  18. Name string `json:"name" example:"central-panel-a"`
  19. Token string `json:"token,omitempty" example:"new-token-string"`
  20. Enabled bool `json:"enabled" example:"true"`
  21. CreatedAt int64 `json:"createdAt" example:"1736000000"`
  22. Scope string `json:"scope" example:"admin"`
  23. ExpiresAt int64 `json:"expiresAt" example:"0"`
  24. }
  25. func apiTokenCreatedAtSeconds(createdAt int64) int64 {
  26. if createdAt >= model.ApiTokenUnixMillisecondsThreshold {
  27. return createdAt / 1000
  28. }
  29. return createdAt
  30. }
  31. // toView builds the metadata view returned by List. It never carries the
  32. // token value: only a SHA-256 hash is stored, and the plaintext is shown
  33. // exactly once at creation time.
  34. func toView(t *model.ApiToken) *ApiTokenView {
  35. return &ApiTokenView{
  36. Id: t.Id,
  37. Name: t.Name,
  38. Enabled: t.Enabled,
  39. CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt),
  40. Scope: t.Scope,
  41. ExpiresAt: t.ExpiresAt,
  42. }
  43. }
  44. // NormalizeScope validates a requested scope, defaulting empty to admin so
  45. // callers that omit it keep the legacy full-access behavior.
  46. func NormalizeScope(scope string) (string, error) {
  47. switch strings.ToLower(strings.TrimSpace(scope)) {
  48. case "", model.ApiScopeAdmin:
  49. return model.ApiScopeAdmin, nil
  50. case model.ApiScopeMonitor:
  51. return model.ApiScopeMonitor, nil
  52. case model.ApiScopeNodeSync:
  53. return model.ApiScopeNodeSync, nil
  54. default:
  55. return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'")
  56. }
  57. }
  58. func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
  59. db := database.GetDB()
  60. var rows []*model.ApiToken
  61. if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
  62. return nil, err
  63. }
  64. out := make([]*ApiTokenView, 0, len(rows))
  65. for _, r := range rows {
  66. out = append(out, toView(r))
  67. }
  68. return out, nil
  69. }
  70. func (s *ApiTokenService) Create(name, scope string, expiresAt int64) (*ApiTokenView, error) {
  71. name = strings.TrimSpace(name)
  72. if name == "" {
  73. return nil, common.NewError("token name is required")
  74. }
  75. if len(name) > 64 {
  76. return nil, common.NewError("token name must be 64 characters or fewer")
  77. }
  78. normScope, err := NormalizeScope(scope)
  79. if err != nil {
  80. return nil, err
  81. }
  82. if expiresAt < 0 || (expiresAt != 0 && expiresAt <= nowMilli()) {
  83. return nil, common.NewError("expiresAt must be 0 (never) or a future unix-ms timestamp")
  84. }
  85. db := database.GetDB()
  86. var count int64
  87. if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
  88. return nil, err
  89. }
  90. if count > 0 {
  91. return nil, common.NewError("a token with that name already exists")
  92. }
  93. plaintext := random.Seq(apiTokenLength)
  94. row := &model.ApiToken{
  95. Name: name,
  96. Token: crypto.HashTokenSHA256(plaintext),
  97. Enabled: true,
  98. Scope: normScope,
  99. ExpiresAt: expiresAt,
  100. }
  101. if err := db.Create(row).Error; err != nil {
  102. return nil, err
  103. }
  104. view := toView(row)
  105. view.Token = plaintext
  106. return view, nil
  107. }
  108. // RecreateByName replaces any token with this name, keeping exactly one so a
  109. // repeatedly-run caller cannot accumulate credentials it can never revoke.
  110. func (s *ApiTokenService) RecreateByName(name string) (*ApiTokenView, error) {
  111. name = strings.TrimSpace(name)
  112. if name == "" {
  113. return nil, common.NewError("token name is required")
  114. }
  115. plaintext := random.Seq(apiTokenLength)
  116. row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
  117. if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
  118. if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
  119. return err
  120. }
  121. return tx.Create(row).Error
  122. }); err != nil {
  123. return nil, err
  124. }
  125. view := toView(row)
  126. view.Token = plaintext
  127. return view, nil
  128. }
  129. func (s *ApiTokenService) Delete(id int) error {
  130. if id <= 0 {
  131. return common.NewError("invalid token id")
  132. }
  133. db := database.GetDB()
  134. return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
  135. }
  136. func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error {
  137. if id <= 0 {
  138. return common.NewError("invalid token id")
  139. }
  140. scope, err := requireExpectedScope(expectedScope)
  141. if err != nil {
  142. return err
  143. }
  144. res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
  145. if res.Error != nil {
  146. return res.Error
  147. }
  148. if res.RowsAffected == 0 {
  149. return errors.New("token not found with expected scope")
  150. }
  151. return nil
  152. }
  153. func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
  154. if id <= 0 {
  155. return common.NewError("invalid token id")
  156. }
  157. db := database.GetDB()
  158. res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
  159. if res.Error != nil {
  160. return res.Error
  161. }
  162. if res.RowsAffected == 0 {
  163. return errors.New("token not found")
  164. }
  165. return nil
  166. }
  167. func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
  168. if id <= 0 {
  169. return common.NewError("invalid token id")
  170. }
  171. scope, err := requireExpectedScope(expectedScope)
  172. if err != nil {
  173. return err
  174. }
  175. res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
  176. if res.Error != nil {
  177. return res.Error
  178. }
  179. if res.RowsAffected == 0 {
  180. return errors.New("token not found with expected scope")
  181. }
  182. return nil
  183. }
  184. func nowMilli() int64 { return time.Now().UnixMilli() }
  185. // DisableExpectedScope fails closed unless the stored scope matches the caller,
  186. // preventing rotation from revoking a newly minted token after a wrong ID.
  187. func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
  188. if id <= 0 {
  189. return common.NewError("invalid token id")
  190. }
  191. return s.SetEnabledExpectedScope(id, expectedScope, false)
  192. }
  193. func requireExpectedScope(expectedScope string) (string, error) {
  194. if strings.TrimSpace(expectedScope) == "" {
  195. return "", common.NewError("expected scope is required")
  196. }
  197. scope, err := NormalizeScope(expectedScope)
  198. if err != nil {
  199. return "", err
  200. }
  201. return scope, nil
  202. }
  203. // MatchToken returns the enabled, non-expired api_token row whose stored
  204. // SHA-256 hash matches the presented bearer value, or (nil,false). The loop
  205. // scans every enabled row with constant-time compares, then applies expiry and
  206. // scope checks to avoid treating corrupt values as admin.
  207. func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
  208. if presented == "" {
  209. return nil, false
  210. }
  211. db := database.GetDB()
  212. var rows []*model.ApiToken
  213. if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
  214. return nil, false
  215. }
  216. presentedHash := []byte(crypto.HashTokenSHA256(presented))
  217. var matched *model.ApiToken
  218. for _, r := range rows {
  219. if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
  220. matched = r
  221. }
  222. }
  223. if matched == nil {
  224. return nil, false
  225. }
  226. if !model.IsKnownApiScope(matched.Scope) {
  227. return nil, false
  228. }
  229. if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
  230. return nil, false
  231. }
  232. return matched, true
  233. }
  234. // Match is the legacy boolean form for callers that do not need scope.
  235. func (s *ApiTokenService) Match(presented string) bool {
  236. _, ok := s.MatchToken(presented)
  237. return ok
  238. }