api_token.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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. // Same column, same limit as Create: the CLI now feeds this operator input.
  116. if len(name) > 64 {
  117. return nil, common.NewError("token name must be 64 characters or fewer")
  118. }
  119. plaintext := random.Seq(apiTokenLength)
  120. row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
  121. if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
  122. if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
  123. return err
  124. }
  125. return tx.Create(row).Error
  126. }); err != nil {
  127. return nil, err
  128. }
  129. view := toView(row)
  130. view.Token = plaintext
  131. return view, nil
  132. }
  133. func (s *ApiTokenService) Delete(id int) error {
  134. if id <= 0 {
  135. return common.NewError("invalid token id")
  136. }
  137. db := database.GetDB()
  138. return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
  139. }
  140. func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error {
  141. if id <= 0 {
  142. return common.NewError("invalid token id")
  143. }
  144. scope, err := requireExpectedScope(expectedScope)
  145. if err != nil {
  146. return err
  147. }
  148. res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
  149. if res.Error != nil {
  150. return res.Error
  151. }
  152. if res.RowsAffected == 0 {
  153. return errors.New("token not found with expected scope")
  154. }
  155. return nil
  156. }
  157. func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
  158. if id <= 0 {
  159. return common.NewError("invalid token id")
  160. }
  161. db := database.GetDB()
  162. res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
  163. if res.Error != nil {
  164. return res.Error
  165. }
  166. if res.RowsAffected == 0 {
  167. return errors.New("token not found")
  168. }
  169. return nil
  170. }
  171. func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
  172. if id <= 0 {
  173. return common.NewError("invalid token id")
  174. }
  175. scope, err := requireExpectedScope(expectedScope)
  176. if err != nil {
  177. return err
  178. }
  179. res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
  180. if res.Error != nil {
  181. return res.Error
  182. }
  183. if res.RowsAffected == 0 {
  184. return errors.New("token not found with expected scope")
  185. }
  186. return nil
  187. }
  188. func nowMilli() int64 { return time.Now().UnixMilli() }
  189. // DisableExpectedScope fails closed unless the stored scope matches the caller,
  190. // preventing rotation from revoking a newly minted token after a wrong ID.
  191. func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
  192. if id <= 0 {
  193. return common.NewError("invalid token id")
  194. }
  195. return s.SetEnabledExpectedScope(id, expectedScope, false)
  196. }
  197. func requireExpectedScope(expectedScope string) (string, error) {
  198. if strings.TrimSpace(expectedScope) == "" {
  199. return "", common.NewError("expected scope is required")
  200. }
  201. scope, err := NormalizeScope(expectedScope)
  202. if err != nil {
  203. return "", err
  204. }
  205. return scope, nil
  206. }
  207. // MatchToken returns the enabled, non-expired api_token row whose stored
  208. // SHA-256 hash matches the presented bearer value, or (nil,false). The loop
  209. // scans every enabled row with constant-time compares, then applies expiry and
  210. // scope checks to avoid treating corrupt values as admin.
  211. func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
  212. if presented == "" {
  213. return nil, false
  214. }
  215. db := database.GetDB()
  216. var rows []*model.ApiToken
  217. if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
  218. return nil, false
  219. }
  220. presentedHash := []byte(crypto.HashTokenSHA256(presented))
  221. var matched *model.ApiToken
  222. for _, r := range rows {
  223. if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
  224. matched = r
  225. }
  226. }
  227. if matched == nil {
  228. return nil, false
  229. }
  230. if !model.IsKnownApiScope(matched.Scope) {
  231. return nil, false
  232. }
  233. if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
  234. return nil, false
  235. }
  236. return matched, true
  237. }
  238. // Match is the legacy boolean form for callers that do not need scope.
  239. func (s *ApiTokenService) Match(presented string) bool {
  240. _, ok := s.MatchToken(presented)
  241. return ok
  242. }