feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+201 -1
View File
@@ -7,12 +7,15 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
@@ -30,12 +33,13 @@ func setupDashboardAuthMiddlewareTest(t *testing.T) {
previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.AuditLog{}))
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.AuditLog{}, &model.UserAccessToken{}, &model.Option{}))
model.DB = db
model.LOG_DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.SessionSecret = "middleware-auth-test-secret"
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
t.Cleanup(func() {
model.DB = previousDB
model.LOG_DB = previousLogDB
@@ -87,6 +91,29 @@ func createMiddlewarePATUser(t *testing.T, username, token string) *model.User {
return user
}
// Read-only API key routes (/api/usage/token, /api/log/token) are called by
// scripts, not the web console, so their errors are translated by the backend
// in the language the request asks for.
func TestTokenAuthReadOnlyTranslatesErrors(t *testing.T) {
require.NoError(t, i18n.Init())
for _, tc := range []struct {
acceptLanguage string
want string
}{
{"zh-CN", "未提供令牌"},
{"en-US", "Token not provided"},
} {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/log/token", nil)
c.Request.Header.Set("Accept-Language", tc.acceptLanguage)
TokenAuthReadOnly()(c)
assert.True(t, c.IsAborted(), tc.acceptLanguage)
assert.Equal(t, http.StatusUnauthorized, recorder.Code, tc.acceptLanguage)
assert.JSONEq(t, `{"success":false,"message":"`+tc.want+`"}`, recorder.Body.String(), tc.acceptLanguage)
}
}
func TestUserAuthAllowsOpaqueDottedPAT(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
user := createMiddlewarePATUser(t, "dotted-pat-user", "opaque.key.with-dots")
@@ -135,6 +162,7 @@ func TestTryUserAuthCredentialClassification(t *testing.T) {
patUser := createMiddlewarePATUser(t, "optional-pat-user", "optional.pat.with-dots")
internalUser := createMiddlewarePATUser(t, "optional-session-user", "unrelated-pat")
scopedPAT, _ := createMiddlewareScopedToken(t, patUser.Id, 0, "profile:read")
now := time.Now().Unix()
session := &model.UserSession{
SID: "optional-auth-session",
@@ -189,6 +217,7 @@ func TestTryUserAuthCredentialClassification(t *testing.T) {
{name: "dotted unmatched credential", token: "ordinary.key.with-dots", wantStatus: http.StatusOK},
{name: "third party jwt", token: externalToken, wantStatus: http.StatusOK},
{name: "valid pat", token: "optional.pat.with-dots", wantStatus: http.StatusOK, wantUserID: patUser.Id, wantPAT: true},
{name: "scoped pat outside its grant stays anonymous", token: scopedPAT, wantStatus: http.StatusOK},
{name: "valid internal access jwt", token: accessToken, wantStatus: http.StatusOK, wantUserID: internalUser.Id},
{name: "expired internal access jwt", token: issueExpiredDashboardAccessToken(t, identity), wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_TOKEN_EXPIRED"},
{name: "tampered internal access jwt", token: tamperDashboardToken(accessToken), wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_UNAUTHORIZED"},
@@ -256,6 +285,177 @@ func TestTryUserAuthCredentialClassification(t *testing.T) {
assert.Contains(t, databaseFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR")
}
func createMiddlewareScopedToken(t *testing.T, userID int, expiresAt int64, scopes ...string) (string, *model.UserAccessToken) {
t.Helper()
suffix, err := common.GenerateRandomCharsKey(43)
require.NoError(t, err)
raw := model.AccessTokenPrefix + suffix
token := &model.UserAccessToken{Name: "middleware token", TokenHash: model.AccessTokenFingerprint(raw), TokenHint: model.AccessTokenHint(raw), ExpiresAt: expiresAt}
require.NoError(t, token.SetScopes(scopes))
require.NoError(t, model.CreateUserAccessToken(userID, token, service.AccessTokenMaxPerUser))
return raw, token
}
func middlewareBearerRequest(router *gin.Engine, path, token string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func TestUserAuthAppliesAccessTokenRouteRules(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
gin.SetMode(gin.TestMode)
wasMaster := common.IsMasterNode
common.IsMasterNode = true
t.Cleanup(func() { common.IsMasterNode = wasMaster })
require.NoError(t, model.DB.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
require.NoError(t, authz.Init(model.DB))
// The channel router declares this rule when it registers the route.
DeclareAccessTokenPermissionRoute(http.MethodGet, "/api/channel/", authz.ChannelRead)
legacy := "middleware-legacy-token"
admin := createMiddlewarePATUser(t, "route-rule-admin", legacy)
require.NoError(t, model.DB.Model(admin).Update("role", common.RoleAdminUser).Error)
profile, _ := createMiddlewareScopedToken(t, admin.Id, 0, "profile:read")
channel, _ := createMiddlewareScopedToken(t, admin.Id, time.Now().Unix()+3600, "channel:read")
expired, _ := createMiddlewareScopedToken(t, admin.Id, time.Now().Unix()-1, "profile:read")
ok := func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"id": c.GetInt("id")}) }
router := gin.New()
router.GET("/api/user/self", UserAuth(), ok)
router.GET("/api/user/access_tokens", UserAuth(), ok)
router.GET("/api/undeclared", UserAuth(), ok)
router.GET("/api/channel/", AdminAuth(), RequirePermission(authz.ChannelRead), ok)
router.GET("/api/pricing", TryUserAuth(), ok)
for _, test := range []struct {
name, path, token, code, reason string
status int
}{
{name: "granted scope on a never expiring token", path: "/api/user/self", token: profile, status: http.StatusOK},
{name: "missing scope", path: "/api/user/self", token: channel, status: http.StatusForbidden, code: "ACCESS_TOKEN_SCOPE_DENIED", reason: "scope_denied"},
{name: "undeclared route", path: "/api/undeclared", token: profile, status: http.StatusForbidden, code: "ACCESS_TOKEN_ROUTE_UNDECLARED", reason: "route_undeclared"},
{name: "session route", path: "/api/user/access_tokens", token: profile, status: http.StatusForbidden, code: "AUTH_SESSION_REQUIRED", reason: "session_required"},
{name: "permission route with its scope", path: "/api/channel/", token: channel, status: http.StatusOK},
{name: "permission route without its scope", path: "/api/channel/", token: profile, status: http.StatusForbidden, code: "ACCESS_TOKEN_SCOPE_DENIED", reason: "scope_denied"},
{name: "expired token", path: "/api/user/self", token: expired, status: http.StatusUnauthorized, code: "ACCESS_TOKEN_EXPIRED", reason: "expired"},
{name: "legacy token before the deadline", path: "/api/user/self", token: legacy, status: http.StatusOK},
{name: "legacy token on a session route", path: "/api/user/access_tokens", token: legacy, status: http.StatusForbidden, code: "AUTH_SESSION_REQUIRED", reason: "session_required"},
} {
t.Run(test.name, func(t *testing.T) {
response := middlewareBearerRequest(router, test.path, test.token)
assert.Equal(t, test.status, response.Code, response.Body.String())
if test.code != "" {
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
}
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("category = ?", model.AuditCategoryAccessToken).Order("id desc").First(&audit).Error)
assert.Equal(t, model.AccessTokenFingerprint(test.token), audit.TokenRef)
assert.Equal(t, test.status, audit.Status)
if test.reason == "" {
return
}
require.NotNil(t, audit.Other.Op)
params, err := common.Marshal(audit.Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), `"failure_reason":"`+test.reason+`"`)
if test.code == "ACCESS_TOKEN_SCOPE_DENIED" {
assert.Contains(t, string(params), `"required_scope":"`)
}
})
}
// The token grant never widens the account: revoking the Casbin permission
// rejects a token that still carries the scope.
require.NoError(t, authz.SetUserPermissions(admin.Id, authz.PermissionsMap{authz.ResourceChannel: {authz.ActionRead: false}}))
response := middlewareBearerRequest(router, "/api/channel/", channel)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.NotContains(t, response.Body.String(), "ACCESS_TOKEN_SCOPE_DENIED")
require.NoError(t, model.DB.Model(admin).Update("status", common.UserStatusDisabled).Error)
response = middlewareBearerRequest(router, "/api/user/self", profile)
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), `"code":"AUTH_USER_DISABLED"`)
require.NoError(t, model.DB.Model(admin).Update("status", common.UserStatusEnabled).Error)
assert.Equal(t, http.StatusOK, middlewareBearerRequest(router, "/api/user/self", profile).Code, "disabling a user keeps the token")
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Update("value", fmt.Sprint(time.Now().Unix()-1)).Error)
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
response = middlewareBearerRequest(router, "/api/user/self", legacy)
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_LEGACY_RETIRED"`)
response = middlewareBearerRequest(router, "/api/pricing", legacy)
assert.Equal(t, http.StatusOK, response.Code)
assert.JSONEq(t, `{"id":0}`, response.Body.String(), "optional authentication treats a retired token as anonymous")
assert.Equal(t, http.StatusOK, middlewareBearerRequest(router, "/api/user/self", profile).Code, "the deadline only retires legacy tokens")
}
func TestAccessTokenIdentityAndSingleLookup(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
gin.SetMode(gin.TestMode)
legacy := "middleware-identity-legacy"
user := createMiddlewarePATUser(t, "identity-user", legacy)
scoped, token := createMiddlewareScopedToken(t, user.Id, 0, "profile:read")
router := gin.New()
router.Use(AccessTokenAudit())
router.GET("/api/user/self", UserAuth(), func(c *gin.Context) {
_, session := GetSessionAuthIdentity(c)
identity, stepUp := GetStepUpIdentity(c)
c.JSON(http.StatusOK, gin.H{"session": session, "step_up": stepUp, "identity": identity})
})
var queries []string
const callbackName = "test:access-token-lookup-count"
require.NoError(t, model.DB.Callback().Query().After("gorm:query").Register(callbackName, func(tx *gorm.DB) {
queries = append(queries, tx.Statement.Table+" "+tx.Statement.SQL.String())
}))
t.Cleanup(func() { model.DB.Callback().Query().Remove(callbackName) })
countQueries := func(table, fragment string) int {
count := 0
for _, query := range queries {
if strings.HasPrefix(query, table+" ") && strings.Contains(query, fragment) {
count++
}
}
return count
}
for _, test := range []struct {
name, token string
stepUp bool
tokenTable int
legacyQuery int
}{
{name: "scoped token", token: scoped, stepUp: true, tokenTable: 1},
{name: "legacy token", token: legacy, legacyQuery: 1},
} {
t.Run(test.name, func(t *testing.T) {
queries = nil
response := middlewareBearerRequest(router, "/api/user/self", test.token)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var body struct {
Session bool `json:"session"`
StepUp bool `json:"step_up"`
Identity service.AuthIdentity `json:"identity"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.False(t, body.Session, "an access token is never a browser session")
assert.Equal(t, test.stepUp, body.StepUp)
if test.stepUp {
assert.Equal(t, service.AuthIdentity{UserID: user.Id, SessionID: model.AccessTokenSessionID(token.Id), UserAuthVersion: user.AuthVersion, SessionVersion: model.AccessTokenSessionVersion}, body.Identity)
}
// The access audit and authentication share one lookup: the
// credential query plus the owner's user row.
assert.Len(t, queries, 2)
assert.Equal(t, test.tokenTable, countQueries("user_access_tokens", "token_hash"))
assert.Equal(t, test.legacyQuery, countQueries("users", "access_token = "))
})
}
}
func TestAPIKeyFromWebSocketSubprotocol(t *testing.T) {
tests := []struct {
name string