mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend
Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.
Text is now translated where its reader is:
- Web console APIs return the English source text as message_key with
message_params, and the web console translates it in all seven languages.
message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
the backend (en, zh-CN, zh-TW) in the language saved on the account, then
the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
messages that were Chinese before, word for word. Gateways that use another
new-api as their upstream match that text, for example to disable a channel
whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
params) and rendered in the viewer's language. Task fail reasons and refund
reasons written by the gateway are English keys. Rows written earlier are
shown as before.
- Server logs are English.
Also:
- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
non-English string literal, on a web console message without translations
and on a locale key without a constant.
* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it
Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.
common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.
* docs: list LOG_LANGUAGE in the docker compose example
* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE
Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.
DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.
Review fixes:
- Channel test logs store the token name "Model test"; the console shows
it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
translates. The HTTP status and the upstream detail go to the server log
when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.
Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
translated message key.
- Remove guards and helpers that no caller needs.
* fix(i18n): address review on language lookup, notices and error responses
- Relay requests no longer load the user a second time to find the
language: the settings TokenAuth put into the request are used as they
are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
messages again, with the keys and status codes used before this PR.
Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
senders. Running the rendered text through Sprintf appended
%!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
537 lines
22 KiB
Go
537 lines
22 KiB
Go
package middleware
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"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"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func setupDashboardAuthMiddlewareTest(t *testing.T) {
|
|
t.Helper()
|
|
previousDB := model.DB
|
|
previousLogDB := model.LOG_DB
|
|
previousType := common.MainDatabaseType()
|
|
previousRedis := common.RedisEnabled
|
|
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{}, &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
|
|
common.SetMainDatabaseType(previousType)
|
|
common.RedisEnabled = previousRedis
|
|
common.SessionSecret = previousSecret
|
|
})
|
|
}
|
|
|
|
func issueExpiredDashboardAccessToken(t *testing.T, identity service.AuthIdentity) string {
|
|
t.Helper()
|
|
claims := jwt.MapClaims{
|
|
"iss": "new-api",
|
|
"aud": []string{"new-api-dashboard"},
|
|
"sub": fmt.Sprintf("%d", identity.UserID),
|
|
"token_use": "access",
|
|
"sid": identity.SessionID,
|
|
"uv": identity.UserAuthVersion,
|
|
"sv": identity.SessionVersion,
|
|
"exp": time.Now().Add(-time.Minute).Unix(),
|
|
"nbf": time.Now().Add(-2 * time.Minute).Unix(),
|
|
"iat": time.Now().Add(-2 * time.Minute).Unix(),
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(common.SessionSecret))
|
|
_, err := mac.Write([]byte("new-api/auth/access/v1"))
|
|
require.NoError(t, err)
|
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(mac.Sum(nil))
|
|
require.NoError(t, err)
|
|
return token
|
|
}
|
|
|
|
func tamperDashboardToken(token string) string {
|
|
tamperAt := len(token) - 2
|
|
replacement := "x"
|
|
if token[tamperAt] == 'x' {
|
|
replacement = "y"
|
|
}
|
|
return token[:tamperAt] + replacement + token[tamperAt+1:]
|
|
}
|
|
|
|
func createMiddlewarePATUser(t *testing.T, username, token string) *model.User {
|
|
t.Helper()
|
|
user := &model.User{
|
|
Username: username, Password: "password-placeholder", Role: common.RoleCommonUser,
|
|
Status: common.UserStatusEnabled, Group: "default", AccessToken: &token, AuthVersion: 1,
|
|
AffCode: "middleware-aff-" + username,
|
|
}
|
|
require.NoError(t, model.DB.Create(user).Error)
|
|
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")
|
|
router := gin.New()
|
|
router.GET("/protected", UserAuth(), func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"id": c.GetInt("id")})
|
|
})
|
|
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
request.Header.Set("Authorization", "Bearer opaque.key.with-dots")
|
|
response := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
var body struct {
|
|
ID int `json:"id"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.Equal(t, user.Id, body.ID)
|
|
}
|
|
|
|
func TestUserAuthNeverFallsBackForRecognizedInvalidInternalJWT(t *testing.T) {
|
|
setupDashboardAuthMiddlewareTest(t)
|
|
identity := service.AuthIdentity{UserID: 42, SessionID: "session-42", UserAuthVersion: 1, SessionVersion: 1}
|
|
token, _, err := service.IssueAccessToken(identity)
|
|
require.NoError(t, err)
|
|
tampered := tamperDashboardToken(token)
|
|
createMiddlewarePATUser(t, "jwt-fallback-user", tampered)
|
|
router := gin.New()
|
|
router.GET("/protected", UserAuth(), func(c *gin.Context) {
|
|
c.Status(http.StatusNoContent)
|
|
})
|
|
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
request.Header.Set("Authorization", "Bearer "+tampered)
|
|
response := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, response.Code)
|
|
assert.Contains(t, response.Body.String(), "AUTH_UNAUTHORIZED")
|
|
}
|
|
|
|
func TestTryUserAuthCredentialClassification(t *testing.T) {
|
|
setupDashboardAuthMiddlewareTest(t)
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
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",
|
|
UserID: internalUser.Id,
|
|
Version: 1,
|
|
UserAuthVersion: internalUser.AuthVersion,
|
|
Status: model.UserSessionStatusActive,
|
|
RefreshHash: "refresh-hash",
|
|
LoginMethod: "password",
|
|
LastActiveAt: now,
|
|
ExpiresAt: now + 3600,
|
|
}
|
|
require.NoError(t, model.CreateUserSession(session))
|
|
identity := service.AuthIdentity{
|
|
UserID: internalUser.Id,
|
|
SessionID: session.SID,
|
|
UserAuthVersion: session.UserAuthVersion,
|
|
SessionVersion: session.Version,
|
|
}
|
|
accessToken, _, err := service.IssueAccessToken(identity)
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.AutoMigrate(&model.AuthFlow{}))
|
|
binding, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)})
|
|
require.NoError(t, err)
|
|
securityProof, _, err := service.IssueSecurityProof(identity, "2fa", binding)
|
|
require.NoError(t, err)
|
|
externalToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"iss": "external-issuer",
|
|
"aud": "external-audience",
|
|
"exp": time.Now().Add(time.Minute).Unix(),
|
|
}).SignedString([]byte("external-secret"))
|
|
require.NoError(t, err)
|
|
|
|
router := gin.New()
|
|
router.GET("/optional", TryUserAuth(), func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": c.GetInt("id"),
|
|
"use_access_token": c.GetBool("use_access_token"),
|
|
})
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
wantStatus int
|
|
wantUserID int
|
|
wantPAT bool
|
|
wantErrorCode string
|
|
}{
|
|
{name: "no authorization header", wantStatus: http.StatusOK},
|
|
{name: "opaque unmatched credential", token: "opaque-relay-key", wantStatus: http.StatusOK},
|
|
{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"},
|
|
{name: "security proof used as access", token: securityProof, wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_UNAUTHORIZED"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodGet, "/optional", nil)
|
|
if test.token != "" {
|
|
request.Header.Set("Authorization", "Bearer "+test.token)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
assert.Equal(t, test.wantStatus, response.Code)
|
|
if test.wantErrorCode != "" {
|
|
assert.Contains(t, response.Body.String(), test.wantErrorCode)
|
|
return
|
|
}
|
|
var body struct {
|
|
ID int `json:"id"`
|
|
UseAccessToken bool `json:"use_access_token"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
|
|
assert.Equal(t, test.wantUserID, body.ID)
|
|
assert.Equal(t, test.wantPAT, body.UseAccessToken)
|
|
})
|
|
}
|
|
|
|
requiredRouter := gin.New()
|
|
requiredRouter.GET("/required", UserAuth(), func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
|
requiredRequest := httptest.NewRequest(http.MethodGet, "/required", nil)
|
|
requiredRequest.Header.Set("Authorization", "Bearer ordinary-unmatched-key")
|
|
requiredResponse := httptest.NewRecorder()
|
|
requiredRouter.ServeHTTP(requiredResponse, requiredRequest)
|
|
assert.Equal(t, http.StatusUnauthorized, requiredResponse.Code, "required dashboard authentication must not adopt optional-auth fallback semantics")
|
|
|
|
var patUserQueries int
|
|
forcedCacheError := errors.New("forced PAT user cache lookup failure")
|
|
const callbackName = "test:optional-auth-pat-user-cache-failure"
|
|
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
|
|
if tx.Statement.Table != "users" {
|
|
return
|
|
}
|
|
patUserQueries++
|
|
if patUserQueries == 2 {
|
|
tx.AddError(forcedCacheError)
|
|
}
|
|
}))
|
|
cacheFailureRequest := httptest.NewRequest(http.MethodGet, "/optional", nil)
|
|
cacheFailureRequest.Header.Set("Authorization", "Bearer optional.pat.with-dots")
|
|
cacheFailureResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(cacheFailureResponse, cacheFailureRequest)
|
|
model.DB.Callback().Query().Remove(callbackName)
|
|
assert.Equal(t, http.StatusInternalServerError, cacheFailureResponse.Code)
|
|
assert.Contains(t, cacheFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR")
|
|
|
|
sqlDB, err := model.DB.DB()
|
|
require.NoError(t, err)
|
|
require.NoError(t, sqlDB.Close())
|
|
databaseFailureRequest := httptest.NewRequest(http.MethodGet, "/optional", nil)
|
|
databaseFailureRequest.Header.Set("Authorization", "Bearer database-failure-key")
|
|
databaseFailureResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(databaseFailureResponse, databaseFailureRequest)
|
|
assert.Equal(t, http.StatusInternalServerError, databaseFailureResponse.Code)
|
|
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
|
|
protocols string
|
|
wantKey string
|
|
wantOK bool
|
|
}{
|
|
{
|
|
name: "responses protocol only",
|
|
protocols: "responses",
|
|
wantOK: false,
|
|
},
|
|
{
|
|
name: "realtime protocol only",
|
|
protocols: "realtime",
|
|
wantOK: false,
|
|
},
|
|
{
|
|
name: "responses with insecure key",
|
|
protocols: "responses, openai-insecure-api-key.sk-test",
|
|
wantKey: "sk-test",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "realtime with beta and insecure key",
|
|
protocols: "realtime, openai-insecure-api-key.sk-realtime, openai-beta.realtime-v1",
|
|
wantKey: "sk-realtime",
|
|
wantOK: true,
|
|
},
|
|
{
|
|
name: "empty insecure key",
|
|
protocols: "responses, openai-insecure-api-key.",
|
|
wantOK: false,
|
|
},
|
|
{
|
|
name: "bare insecure marker is not a key",
|
|
protocols: "openai-insecure-api-key",
|
|
wantOK: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotKey, gotOK := apiKeyFromWebSocketSubprotocol(tt.protocols)
|
|
assert.Equal(t, tt.wantOK, gotOK)
|
|
assert.Equal(t, tt.wantKey, gotKey)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyWebSocketSubprotocolAuthorizationDoesNotOverrideProtocolOnly(t *testing.T) {
|
|
header := http.Header{}
|
|
header.Set("Authorization", "Bearer sk-original")
|
|
header.Set("Sec-WebSocket-Protocol", "responses")
|
|
header.Add("Sec-WebSocket-Protocol", "openai-beta.realtime-v1")
|
|
|
|
assert.False(t, applyWebSocketSubprotocolAuthorization(header))
|
|
assert.Equal(t, "Bearer sk-original", header.Get("Authorization"))
|
|
}
|
|
|
|
func TestApplyWebSocketSubprotocolAuthorizationOverridesWithInsecureKey(t *testing.T) {
|
|
header := http.Header{}
|
|
header.Set("Authorization", "Bearer sk-original")
|
|
header.Set("Sec-WebSocket-Protocol", "responses, openai-insecure-api-key.sk-from-protocol")
|
|
|
|
assert.True(t, applyWebSocketSubprotocolAuthorization(header))
|
|
assert.Equal(t, "Bearer sk-from-protocol", header.Get("Authorization"))
|
|
}
|
|
|
|
func TestApplyWebSocketSubprotocolAuthorizationReadsRepeatedHeaders(t *testing.T) {
|
|
header := http.Header{}
|
|
header.Set("Authorization", "Bearer sk-original")
|
|
header.Add("Sec-WebSocket-Protocol", "responses")
|
|
header.Add("Sec-WebSocket-Protocol", "openai-insecure-api-key.sk-later-field")
|
|
|
|
assert.True(t, applyWebSocketSubprotocolAuthorization(header))
|
|
assert.Equal(t, "Bearer sk-later-field", header.Get("Authorization"))
|
|
}
|