api_token.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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, scope 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. givenScope := ""
  120. if strings.TrimSpace(scope) != "" {
  121. var err error
  122. if givenScope, err = NormalizeScope(scope); err != nil {
  123. return nil, err
  124. }
  125. }
  126. plaintext := random.Seq(apiTokenLength)
  127. row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true, Scope: givenScope}
  128. if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
  129. var replaced []model.ApiToken
  130. if err := tx.Where("name = ?", name).Order("id asc").Limit(1).Find(&replaced).Error; err != nil {
  131. return err
  132. }
  133. if len(replaced) > 0 {
  134. // A rotation keeps the deadline the token was issued with; reviving an
  135. // expired one would silently hand back a credential that never expires.
  136. if replaced[0].ExpiresAt != 0 && nowMilli() >= replaced[0].ExpiresAt {
  137. return common.NewErrorf("token %q has expired; create a new token from the panel or the API instead", name)
  138. }
  139. row.ExpiresAt = replaced[0].ExpiresAt
  140. }
  141. if row.Scope == "" {
  142. // An empty Scope takes the column default of admin, so a rotated
  143. // monitor or node-sync token would silently gain full access.
  144. row.Scope = model.ApiScopeAdmin
  145. if len(replaced) > 0 {
  146. if !model.IsKnownApiScope(replaced[0].Scope) {
  147. return common.NewErrorf("token %q has unknown scope %q", name, replaced[0].Scope)
  148. }
  149. row.Scope = replaced[0].Scope
  150. }
  151. }
  152. if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
  153. return err
  154. }
  155. return tx.Create(row).Error
  156. }); err != nil {
  157. return nil, err
  158. }
  159. view := toView(row)
  160. view.Token = plaintext
  161. return view, nil
  162. }
  163. func (s *ApiTokenService) Delete(id int) error {
  164. if id <= 0 {
  165. return common.NewError("invalid token id")
  166. }
  167. db := database.GetDB()
  168. return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
  169. }
  170. func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error {
  171. if id <= 0 {
  172. return common.NewError("invalid token id")
  173. }
  174. scope, err := requireExpectedScope(expectedScope)
  175. if err != nil {
  176. return err
  177. }
  178. res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
  179. if res.Error != nil {
  180. return res.Error
  181. }
  182. if res.RowsAffected == 0 {
  183. return errors.New("token not found with expected scope")
  184. }
  185. return nil
  186. }
  187. func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
  188. if id <= 0 {
  189. return common.NewError("invalid token id")
  190. }
  191. db := database.GetDB()
  192. res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
  193. if res.Error != nil {
  194. return res.Error
  195. }
  196. if res.RowsAffected == 0 {
  197. return errors.New("token not found")
  198. }
  199. return nil
  200. }
  201. func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
  202. if id <= 0 {
  203. return common.NewError("invalid token id")
  204. }
  205. scope, err := requireExpectedScope(expectedScope)
  206. if err != nil {
  207. return err
  208. }
  209. res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
  210. if res.Error != nil {
  211. return res.Error
  212. }
  213. if res.RowsAffected == 0 {
  214. return errors.New("token not found with expected scope")
  215. }
  216. return nil
  217. }
  218. func nowMilli() int64 { return time.Now().UnixMilli() }
  219. // DisableExpectedScope fails closed unless the stored scope matches the caller,
  220. // preventing rotation from revoking a newly minted token after a wrong ID.
  221. func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
  222. if id <= 0 {
  223. return common.NewError("invalid token id")
  224. }
  225. return s.SetEnabledExpectedScope(id, expectedScope, false)
  226. }
  227. func requireExpectedScope(expectedScope string) (string, error) {
  228. if strings.TrimSpace(expectedScope) == "" {
  229. return "", common.NewError("expected scope is required")
  230. }
  231. scope, err := NormalizeScope(expectedScope)
  232. if err != nil {
  233. return "", err
  234. }
  235. return scope, nil
  236. }
  237. // MatchToken returns the enabled, non-expired api_token row whose stored
  238. // SHA-256 hash matches the presented bearer value, or (nil,false). The loop
  239. // scans every enabled row with constant-time compares, then applies expiry and
  240. // scope checks to avoid treating corrupt values as admin.
  241. func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
  242. if presented == "" {
  243. return nil, false
  244. }
  245. db := database.GetDB()
  246. var rows []*model.ApiToken
  247. if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
  248. return nil, false
  249. }
  250. presentedHash := []byte(crypto.HashTokenSHA256(presented))
  251. var matched *model.ApiToken
  252. for _, r := range rows {
  253. if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
  254. matched = r
  255. }
  256. }
  257. if matched == nil {
  258. return nil, false
  259. }
  260. if !model.IsKnownApiScope(matched.Scope) {
  261. return nil, false
  262. }
  263. if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
  264. return nil, false
  265. }
  266. return matched, true
  267. }
  268. // Match is the legacy boolean form for callers that do not need scope.
  269. func (s *ApiTokenService) Match(presented string) bool {
  270. _, ok := s.MatchToken(presented)
  271. return ok
  272. }