api_token.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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. case model.ApiScopeNodeAdmin:
  55. return model.ApiScopeNodeAdmin, nil
  56. default:
  57. return "", common.NewError("scope must be 'admin', 'monitor', 'node-sync', or 'node-admin'")
  58. }
  59. }
  60. func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
  61. db := database.GetDB()
  62. var rows []*model.ApiToken
  63. if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
  64. return nil, err
  65. }
  66. out := make([]*ApiTokenView, 0, len(rows))
  67. for _, r := range rows {
  68. out = append(out, toView(r))
  69. }
  70. return out, nil
  71. }
  72. func (s *ApiTokenService) Create(name, scope string, expiresAt int64) (*ApiTokenView, error) {
  73. name = strings.TrimSpace(name)
  74. if name == "" {
  75. return nil, common.NewError("token name is required")
  76. }
  77. if len(name) > 64 {
  78. return nil, common.NewError("token name must be 64 characters or fewer")
  79. }
  80. normScope, err := NormalizeScope(scope)
  81. if err != nil {
  82. return nil, err
  83. }
  84. if expiresAt < 0 || (expiresAt != 0 && expiresAt <= nowMilli()) {
  85. return nil, common.NewError("expiresAt must be 0 (never) or a future unix-ms timestamp")
  86. }
  87. db := database.GetDB()
  88. var count int64
  89. if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
  90. return nil, err
  91. }
  92. if count > 0 {
  93. return nil, common.NewError("a token with that name already exists")
  94. }
  95. plaintext := random.Seq(apiTokenLength)
  96. row := &model.ApiToken{
  97. Name: name,
  98. Token: crypto.HashTokenSHA256(plaintext),
  99. Enabled: true,
  100. Scope: normScope,
  101. ExpiresAt: expiresAt,
  102. }
  103. if err := db.Create(row).Error; err != nil {
  104. return nil, err
  105. }
  106. view := toView(row)
  107. view.Token = plaintext
  108. return view, nil
  109. }
  110. // RecreateByName replaces any token with this name, keeping exactly one so a
  111. // repeatedly-run caller cannot accumulate credentials it can never revoke.
  112. func (s *ApiTokenService) RecreateByName(name, scope string) (*ApiTokenView, error) {
  113. name = strings.TrimSpace(name)
  114. if name == "" {
  115. return nil, common.NewError("token name is required")
  116. }
  117. // Same column, same limit as Create: the CLI now feeds this operator input.
  118. if len(name) > 64 {
  119. return nil, common.NewError("token name must be 64 characters or fewer")
  120. }
  121. givenScope := ""
  122. if strings.TrimSpace(scope) != "" {
  123. var err error
  124. if givenScope, err = NormalizeScope(scope); err != nil {
  125. return nil, err
  126. }
  127. }
  128. plaintext := random.Seq(apiTokenLength)
  129. row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true, Scope: givenScope}
  130. if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
  131. var replaced []model.ApiToken
  132. if err := tx.Where("name = ?", name).Order("id asc").Limit(1).Find(&replaced).Error; err != nil {
  133. return err
  134. }
  135. if len(replaced) > 0 {
  136. // A rotation keeps the deadline the token was issued with; reviving an
  137. // expired one would silently hand back a credential that never expires.
  138. if replaced[0].ExpiresAt != 0 && nowMilli() >= replaced[0].ExpiresAt {
  139. return common.NewErrorf("token %q has expired; create a new token from the panel or the API instead", name)
  140. }
  141. row.ExpiresAt = replaced[0].ExpiresAt
  142. }
  143. if row.Scope == "" {
  144. // An empty Scope takes the column default of admin, so a rotated
  145. // monitor or node-sync token would silently gain full access.
  146. row.Scope = model.ApiScopeAdmin
  147. if len(replaced) > 0 {
  148. if !model.IsKnownApiScope(replaced[0].Scope) {
  149. return common.NewErrorf("token %q has unknown scope %q", name, replaced[0].Scope)
  150. }
  151. row.Scope = replaced[0].Scope
  152. }
  153. }
  154. if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
  155. return err
  156. }
  157. return tx.Create(row).Error
  158. }); err != nil {
  159. return nil, err
  160. }
  161. view := toView(row)
  162. view.Token = plaintext
  163. return view, nil
  164. }
  165. func (s *ApiTokenService) Delete(id int) error {
  166. if id <= 0 {
  167. return common.NewError("invalid token id")
  168. }
  169. db := database.GetDB()
  170. return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
  171. }
  172. func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error {
  173. if id <= 0 {
  174. return common.NewError("invalid token id")
  175. }
  176. scope, err := requireExpectedScope(expectedScope)
  177. if err != nil {
  178. return err
  179. }
  180. res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
  181. if res.Error != nil {
  182. return res.Error
  183. }
  184. if res.RowsAffected == 0 {
  185. return errors.New("token not found with expected scope")
  186. }
  187. return nil
  188. }
  189. func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
  190. if id <= 0 {
  191. return common.NewError("invalid token id")
  192. }
  193. db := database.GetDB()
  194. res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
  195. if res.Error != nil {
  196. return res.Error
  197. }
  198. if res.RowsAffected == 0 {
  199. return errors.New("token not found")
  200. }
  201. return nil
  202. }
  203. func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
  204. if id <= 0 {
  205. return common.NewError("invalid token id")
  206. }
  207. scope, err := requireExpectedScope(expectedScope)
  208. if err != nil {
  209. return err
  210. }
  211. res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
  212. if res.Error != nil {
  213. return res.Error
  214. }
  215. if res.RowsAffected == 0 {
  216. return errors.New("token not found with expected scope")
  217. }
  218. return nil
  219. }
  220. func nowMilli() int64 { return time.Now().UnixMilli() }
  221. // DisableExpectedScope fails closed unless the stored scope matches the caller,
  222. // preventing rotation from revoking a newly minted token after a wrong ID.
  223. func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
  224. if id <= 0 {
  225. return common.NewError("invalid token id")
  226. }
  227. return s.SetEnabledExpectedScope(id, expectedScope, false)
  228. }
  229. func requireExpectedScope(expectedScope string) (string, error) {
  230. if strings.TrimSpace(expectedScope) == "" {
  231. return "", common.NewError("expected scope is required")
  232. }
  233. scope, err := NormalizeScope(expectedScope)
  234. if err != nil {
  235. return "", err
  236. }
  237. return scope, nil
  238. }
  239. // MatchToken returns the enabled, non-expired api_token row whose stored
  240. // SHA-256 hash matches the presented bearer value, or (nil,false). The loop
  241. // scans every enabled row with constant-time compares, then applies expiry and
  242. // scope checks to avoid treating corrupt values as admin.
  243. func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
  244. if presented == "" {
  245. return nil, false
  246. }
  247. db := database.GetDB()
  248. var rows []*model.ApiToken
  249. if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
  250. return nil, false
  251. }
  252. presentedHash := []byte(crypto.HashTokenSHA256(presented))
  253. var matched *model.ApiToken
  254. for _, r := range rows {
  255. if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
  256. matched = r
  257. }
  258. }
  259. if matched == nil {
  260. return nil, false
  261. }
  262. if !model.IsKnownApiScope(matched.Scope) {
  263. return nil, false
  264. }
  265. if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
  266. return nil, false
  267. }
  268. return matched, true
  269. }
  270. // Match is the legacy boolean form for callers that do not need scope.
  271. func (s *ApiTokenService) Match(presented string) bool {
  272. _, ok := s.MatchToken(presented)
  273. return ok
  274. }