mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 00:01:02 +00:00
feat(api): scoped, optionally expiring API tokens (#6201)
* security(api): add scoped expiring API tokens * security(api): make scoped token lifecycle enforceable --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -24,6 +25,8 @@ type ApiTokenView struct {
|
||||
Token string `json:"token,omitempty" example:"new-token-string"`
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
CreatedAt int64 `json:"createdAt" example:"1736000000"`
|
||||
Scope string `json:"scope" example:"admin"`
|
||||
ExpiresAt int64 `json:"expiresAt" example:"0"`
|
||||
}
|
||||
|
||||
func apiTokenCreatedAtSeconds(createdAt int64) int64 {
|
||||
@@ -42,6 +45,23 @@ func toView(t *model.ApiToken) *ApiTokenView {
|
||||
Name: t.Name,
|
||||
Enabled: t.Enabled,
|
||||
CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt),
|
||||
Scope: t.Scope,
|
||||
ExpiresAt: t.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeScope validates a requested scope, defaulting empty to admin so
|
||||
// callers that omit it keep the legacy full-access behavior.
|
||||
func NormalizeScope(scope string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||
case "", model.ApiScopeAdmin:
|
||||
return model.ApiScopeAdmin, nil
|
||||
case model.ApiScopeMonitor:
|
||||
return model.ApiScopeMonitor, nil
|
||||
case model.ApiScopeNodeSync:
|
||||
return model.ApiScopeNodeSync, nil
|
||||
default:
|
||||
return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +78,7 @@ func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
|
||||
func (s *ApiTokenService) Create(name, scope string, expiresAt int64) (*ApiTokenView, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, common.NewError("token name is required")
|
||||
@@ -66,6 +86,13 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
|
||||
if len(name) > 64 {
|
||||
return nil, common.NewError("token name must be 64 characters or fewer")
|
||||
}
|
||||
normScope, err := NormalizeScope(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt < 0 || (expiresAt != 0 && expiresAt <= nowMilli()) {
|
||||
return nil, common.NewError("expiresAt must be 0 (never) or a future unix-ms timestamp")
|
||||
}
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
|
||||
@@ -76,9 +103,11 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
|
||||
}
|
||||
plaintext := random.Seq(apiTokenLength)
|
||||
row := &model.ApiToken{
|
||||
Name: name,
|
||||
Token: crypto.HashTokenSHA256(plaintext),
|
||||
Enabled: true,
|
||||
Name: name,
|
||||
Token: crypto.HashTokenSHA256(plaintext),
|
||||
Enabled: true,
|
||||
Scope: normScope,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -118,6 +147,24 @@ func (s *ApiTokenService) Delete(id int) error {
|
||||
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error {
|
||||
if id <= 0 {
|
||||
return common.NewError("invalid token id")
|
||||
}
|
||||
scope, err := requireExpectedScope(expectedScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("token not found with expected scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
|
||||
if id <= 0 {
|
||||
return common.NewError("invalid token id")
|
||||
@@ -133,25 +180,80 @@ func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match returns true when the presented bearer token matches any enabled
|
||||
// row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented
|
||||
// value is hashed before a constant-time compare per row keeps a remote
|
||||
// attacker from timing the comparison byte-by-byte.
|
||||
func (s *ApiTokenService) Match(presented string) bool {
|
||||
func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
|
||||
if id <= 0 {
|
||||
return common.NewError("invalid token id")
|
||||
}
|
||||
scope, err := requireExpectedScope(expectedScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("token not found with expected scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nowMilli() int64 { return time.Now().UnixMilli() }
|
||||
|
||||
// DisableExpectedScope fails closed unless the stored scope matches the caller,
|
||||
// preventing rotation from revoking a newly minted token after a wrong ID.
|
||||
func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
|
||||
if id <= 0 {
|
||||
return common.NewError("invalid token id")
|
||||
}
|
||||
return s.SetEnabledExpectedScope(id, expectedScope, false)
|
||||
}
|
||||
|
||||
func requireExpectedScope(expectedScope string) (string, error) {
|
||||
if strings.TrimSpace(expectedScope) == "" {
|
||||
return "", common.NewError("expected scope is required")
|
||||
}
|
||||
scope, err := NormalizeScope(expectedScope)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
// MatchToken returns the enabled, non-expired api_token row whose stored
|
||||
// SHA-256 hash matches the presented bearer value, or (nil,false). The loop
|
||||
// scans every enabled row with constant-time compares, then applies expiry and
|
||||
// scope checks to avoid treating corrupt values as admin.
|
||||
func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
|
||||
if presented == "" {
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
db := database.GetDB()
|
||||
var rows []*model.ApiToken
|
||||
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
presentedHash := []byte(crypto.HashTokenSHA256(presented))
|
||||
matched := false
|
||||
var matched *model.ApiToken
|
||||
for _, r := range rows {
|
||||
if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
|
||||
matched = true
|
||||
matched = r
|
||||
}
|
||||
}
|
||||
return matched
|
||||
if matched == nil {
|
||||
return nil, false
|
||||
}
|
||||
if !model.IsKnownApiScope(matched.Scope) {
|
||||
return nil, false
|
||||
}
|
||||
if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
|
||||
return nil, false
|
||||
}
|
||||
return matched, true
|
||||
}
|
||||
|
||||
// Match is the legacy boolean form for callers that do not need scope.
|
||||
func (s *ApiTokenService) Match(presented string) bool {
|
||||
_, ok := s.MatchToken(presented)
|
||||
return ok
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user