feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
+201
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user