Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions common/mac.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package common

import (
"fmt"
"net"
"strings"
)

// NormalizeMacAddress validates an EUI-48 address and returns its canonical form.
func NormalizeMacAddress(value string) (string, error) {
hardwareAddress, err := net.ParseMAC(strings.TrimSpace(value))
if err != nil || len(hardwareAddress) != 6 {
return "", fmt.Errorf("invalid MAC address: %q", value)
}
return strings.ToLower(hardwareAddress.String()), nil
}
36 changes: 36 additions & 0 deletions common/mac_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package common

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNormalizeMacAddress(t *testing.T) {
tests := []struct {
name string
input string
expected string
valid bool
}{
{name: "colon separated", input: "94:b6:09:f6:4f:41", expected: "94:b6:09:f6:4f:41", valid: true},
{name: "hyphen separated uppercase", input: "94-B6-09-F6-4F-41", expected: "94:b6:09:f6:4f:41", valid: true},
{name: "surrounding whitespace", input: " 94:b6:09:f6:4f:41 ", expected: "94:b6:09:f6:4f:41", valid: true},
{name: "missing octet", input: "94:b6:09:f6:4f", valid: false},
{name: "eui64 is not a client MAC", input: "94:b6:09:f6:4f:41:00:01", valid: false},
{name: "empty", input: "", valid: false},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := NormalizeMacAddress(test.input)
if !test.valid {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}
12 changes: 12 additions & 0 deletions controller/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ func AddToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
}
if err := token.NormalizeMacLimits(); err != nil {
common.ApiError(c, err)
return
}
// 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
Expand Down Expand Up @@ -219,6 +223,8 @@ func AddToken(c *gin.Context) {
ModelLimitsEnabled: token.ModelLimitsEnabled,
ModelLimits: token.ModelLimits,
AllowIps: token.AllowIps,
MacCheckEnabled: token.MacCheckEnabled,
AllowMacs: token.AllowMacs,
Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry,
}
Expand Down Expand Up @@ -260,6 +266,10 @@ func UpdateToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
}
if err := token.NormalizeMacLimits(); err != nil {
common.ApiError(c, err)
return
}
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
Expand Down Expand Up @@ -297,6 +307,8 @@ func UpdateToken(c *gin.Context) {
cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled
cleanToken.ModelLimits = token.ModelLimits
cleanToken.AllowIps = token.AllowIps
cleanToken.MacCheckEnabled = token.MacCheckEnabled
cleanToken.AllowMacs = token.AllowMacs
cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry
}
Expand Down
40 changes: 40 additions & 0 deletions controller/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,11 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
}

migrateTokenControllerTestDB(t, db)
for _, column := range []string{"mac_check_enabled", "allow_macs"} {
if !db.Migrator().HasColumn(&model.Token{}, column) {
t.Fatalf("expected migrated token schema to contain %s", column)
}
}

if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" {
t.Fatalf("expected migrated key column type varchar(128), got %q", got)
Expand Down Expand Up @@ -504,6 +509,33 @@ func TestGetTokenMasksKeyInResponse(t *testing.T) {
}
}

func TestAddTokenPersistsMacValidation(t *testing.T) {
db := setupTokenControllerTestDB(t)
body := map[string]any{
"name": "mac-restricted-token",
"expired_time": -1,
"unlimited_quota": true,
"model_limits_enabled": false,
"model_limits": "",
"mac_check_enabled": true,
"allow_macs": "94-B6-09-F6-4F-41",
"group": "default",
"cross_group_retry": false,
}

ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1)
AddToken(ctx)

response := decodeAPIResponse(t, recorder)
require.True(t, response.Success, "expected success response, got message: %s", response.Message)

var created model.Token
require.NoError(t, db.First(&created, "user_id = ? AND name = ?", 1, "mac-restricted-token").Error)
require.True(t, created.MacCheckEnabled)
require.NotNil(t, created.AllowMacs)
require.Equal(t, "94:b6:09:f6:4f:41", *created.AllowMacs)
}

func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678")
Expand All @@ -516,6 +548,8 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
"unlimited_quota": true,
"model_limits_enabled": false,
"model_limits": "",
"mac_check_enabled": true,
"allow_macs": "94-B6-09-F6-4F-41\n00:11:22:33:44:55",
"group": "default",
"cross_group_retry": false,
}
Expand All @@ -538,6 +572,12 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("update response leaked raw token key: %s", recorder.Body.String())
}

var updated model.Token
require.NoError(t, db.First(&updated, token.Id).Error)
require.True(t, updated.MacCheckEnabled)
require.NotNil(t, updated.AllowMacs)
require.Equal(t, "94:b6:09:f6:4f:41\n00:11:22:33:44:55", *updated.AllowMacs)
}

func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
Expand Down
39 changes: 39 additions & 0 deletions middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,21 @@ func TokenAuth() func(c *gin.Context) {
logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp)
}

if token.MacCheckEnabled {
clientMac := c.Request.Header.Get("X-Client-Mac")
allowed, err := tokenAllowsMac(token, clientMac)
if err != nil {
common.SysLog(fmt.Sprintf("TokenAuth invalid MAC whitelist configuration for token %d: %v", token.Id, err))
abortWithOpenAiMessage(c, http.StatusInternalServerError, "MAC address whitelist configuration is invalid")
return
}
if !allowed {
abortWithOpenAiMessage(c, http.StatusForbidden, "Your MAC address is not allowed by this token", types.ErrorCodeAccessDenied)
return
}
logger.LogDebug(c, "Client MAC %s passed the token MAC restrictions check", clientMac)
}

userCache, err := model.GetUserCache(token.UserId)
if err != nil {
common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
Expand Down Expand Up @@ -444,6 +459,30 @@ func TokenAuth() func(c *gin.Context) {
}
}

func tokenAllowsMac(token *model.Token, clientMac string) (bool, error) {
if token == nil {
return false, fmt.Errorf("token is nil")
}
if !token.MacCheckEnabled {
return true, nil
}

normalizedClientMac, err := common.NormalizeMacAddress(clientMac)
if err != nil {
return false, nil
}
for _, allowedMac := range token.GetMacLimits() {
normalizedAllowedMac, err := common.NormalizeMacAddress(allowedMac)
if err != nil {
return false, err
}
if normalizedClientMac == normalizedAllowedMac {
return true, nil
}
}
return false, nil
}

func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
if token == nil {
return fmt.Errorf("token is nil")
Expand Down
41 changes: 41 additions & 0 deletions middleware/auth_mac_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package middleware

import (
"testing"

"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestTokenAllowsMac(t *testing.T) {
allowedMacs := "94:b6:09:f6:4f:41\n00:11:22:33:44:55"
invalidMacs := "not-a-mac"

tests := []struct {
name string
token *model.Token
clientMac string
allowed bool
wantError bool
}{
{name: "disabled token skips validation", token: &model.Token{}, allowed: true},
{name: "enabled token accepts listed MAC", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "94-B6-09-F6-4F-41", allowed: true},
{name: "enabled token rejects missing header", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}},
{name: "enabled token rejects malformed header", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "invalid"},
{name: "enabled token rejects unlisted MAC", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "aa:bb:cc:dd:ee:ff"},
{name: "invalid configured MAC fails closed", token: &model.Token{MacCheckEnabled: true, AllowMacs: &invalidMacs}, clientMac: "94:b6:09:f6:4f:41", wantError: true},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
allowed, err := tokenAllowsMac(test.token, test.clientMac)
if test.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, test.allowed, allowed)
})
}
}
52 changes: 51 additions & 1 deletion model/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type Token struct {
ModelLimitsEnabled bool `json:"model_limits_enabled"`
ModelLimits string `json:"model_limits" gorm:"type:text"`
AllowIps *string `json:"allow_ips" gorm:"default:''"`
MacCheckEnabled bool `json:"mac_check_enabled"`
AllowMacs *string `json:"allow_macs" gorm:"type:text"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
Expand Down Expand Up @@ -78,6 +80,54 @@ func (token *Token) GetIpLimits() []string {
return ipLimits
}

func (token *Token) GetMacLimits() []string {
macLimits := make([]string, 0)
if token.AllowMacs == nil {
return macLimits
}
cleanMacs := strings.ReplaceAll(*token.AllowMacs, " ", "")
if cleanMacs == "" {
return macLimits
}
for _, mac := range strings.Split(cleanMacs, "\n") {
mac = strings.TrimSpace(strings.ReplaceAll(mac, ",", ""))
if mac != "" {
macLimits = append(macLimits, mac)
}
}
return macLimits
}

func (token *Token) NormalizeMacLimits() error {
macLimits := token.GetMacLimits()
if len(macLimits) == 0 {
empty := ""
token.AllowMacs = &empty
if token.MacCheckEnabled {
return errors.New("MAC address whitelist is required when MAC validation is enabled")
}
return nil
}

normalizedMacs := make([]string, 0, len(macLimits))
seen := make(map[string]struct{}, len(macLimits))
for _, mac := range macLimits {
normalizedMac, err := common.NormalizeMacAddress(mac)
if err != nil {
return err
}
if _, exists := seen[normalizedMac]; exists {
continue
}
seen[normalizedMac] = struct{}{}
normalizedMacs = append(normalizedMacs, normalizedMac)
}

normalized := strings.Join(normalizedMacs, "\n")
token.AllowMacs = &normalized
return nil
}

func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
var tokens []*Token
var err error
Expand Down Expand Up @@ -321,7 +371,7 @@ func (token *Token) Update() (err error) {
}
}()
err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
"model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error
"model_limits_enabled", "model_limits", "allow_ips", "mac_check_enabled", "allow_macs", "group", "cross_group_retry").Updates(token).Error
return err
}

Expand Down
40 changes: 40 additions & 0 deletions model/token_mac_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package model

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNormalizeMacLimits(t *testing.T) {
t.Run("enabled token requires whitelist", func(t *testing.T) {
token := Token{MacCheckEnabled: true}

require.Error(t, token.NormalizeMacLimits())
require.NotNil(t, token.AllowMacs)
assert.Empty(t, *token.AllowMacs)
})

t.Run("normalizes and deduplicates addresses", func(t *testing.T) {
allowMacs := "94-B6-09-F6-4F-41\n94:b6:09:f6:4f:41\n00:11:22:33:44:55"
token := Token{MacCheckEnabled: true, AllowMacs: &allowMacs}

require.NoError(t, token.NormalizeMacLimits())
require.NotNil(t, token.AllowMacs)
assert.Equal(t, "94:b6:09:f6:4f:41\n00:11:22:33:44:55", *token.AllowMacs)
})

t.Run("rejects invalid address", func(t *testing.T) {
allowMacs := "not-a-mac"
token := Token{AllowMacs: &allowMacs}

require.Error(t, token.NormalizeMacLimits())
})

t.Run("disabled token accepts empty whitelist", func(t *testing.T) {
token := Token{}

require.NoError(t, token.NormalizeMacLimits())
})
}
Loading
Loading