Files
new-api/middleware/auth.go
T
Calcium-Ion 128089fa7d feat(i18n): translate web console messages in the frontend and AI-client errors in the backend (#7712)
* 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.
2026-10-09 21:39:51 +08:00

710 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
authIdentityContextKey = "auth_identity"
accessTokenLookupContextKey = "access_token_lookup"
accessTokenIdentityContextKey = "access_token_identity"
accessTokenSessionRequiredMsg = "a dashboard login session is required"
)
type dashboardCredentialKind int
const (
dashboardCredentialUnmatched dashboardCredentialKind = iota
dashboardCredentialInternal
dashboardCredentialPAT
)
func validUserInfo(username string, role int) bool {
// check username is empty
if strings.TrimSpace(username) == "" {
return false
}
if !common.IsValidateRole(role) {
return false
}
return true
}
func authHelper(c *gin.Context, minRole int) {
if _, started := c.Get(accessTokenAuditContextKey); !started {
defer finishAccessTokenAudit(c)
}
user, identity, useAccessToken, err := authenticateDashboardRequest(c)
if err != nil {
writeDashboardAuthError(c, err)
return
}
if user.Status != common.UserStatusEnabled {
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_USER_DISABLED", common.NewMessage("User has been banned"))
return
}
if user.Role < minRole {
abortWithWebMessage(c, http.StatusForbidden, "AUTH_INSUFFICIENT_PRIVILEGE", common.NewMessage("Unauthorized, insufficient privileges"))
return
}
var lookup *accessTokenLookup
if useAccessToken {
lookup = requestAccessTokenLookup(c)
if !enforceAccessTokenRoute(c, lookup, true) {
return
}
}
if !validUserInfo(user.Username, user.Role) {
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_USER_INVALID", common.NewMessage("Unauthorized, invalid user info"))
return
}
setDashboardAuthContext(c, user, identity, useAccessToken)
if lookup != nil {
setAccessTokenContext(c, lookup)
}
// 管理/root 写操作审计兜底:内聚在鉴权链路里,保证任何经过 AdminAuth/RootAuth
// 的写接口都会自动留痕(无需在路由上单独挂审计中间件,避免漏挂)。
// handler 内手动埋点者会设置 ContextKeyAuditLogged,finishAdminAudit 据此跳过。
var auditWriter *auditResponseWriter
if minRole >= common.RoleAdminUser {
auditWriter = beginAdminAudit(c)
}
c.Next()
finishAdminAudit(c, auditWriter)
}
func TryUserAuth() func(c *gin.Context) {
return func(c *gin.Context) {
if _, started := c.Get(accessTokenAuditContextKey); !started {
defer finishAccessTokenAudit(c)
}
user, identity, credentialKind, err := classifyDashboardCredential(c)
if errors.Is(err, model.ErrLegacyAccessTokenRetired) {
// After the deadline no opaque value is looked up, so a retired
// token is indistinguishable from any unknown value: anonymous.
credentialKind, err = dashboardCredentialUnmatched, nil
}
if err != nil {
writeDashboardAuthError(c, err)
return
}
// Optional authentication never elevates a token beyond its grant: a
// token the route would reject leaves the request anonymous.
var lookup *accessTokenLookup
if credentialKind == dashboardCredentialPAT {
lookup = requestAccessTokenLookup(c)
if user.Status != common.UserStatusEnabled || !enforceAccessTokenRoute(c, lookup, false) {
credentialKind = dashboardCredentialUnmatched
}
}
if credentialKind != dashboardCredentialUnmatched {
setDashboardAuthContext(c, user, identity, credentialKind == dashboardCredentialPAT)
if credentialKind == dashboardCredentialPAT {
setAccessTokenContext(c, lookup)
}
}
c.Next()
}
}
func UserAuth() func(c *gin.Context) {
return func(c *gin.Context) {
authHelper(c, common.RoleCommonUser)
}
}
func AdminAuth() func(c *gin.Context) {
return func(c *gin.Context) {
authHelper(c, common.RoleAdminUser)
}
}
func RootAuth() func(c *gin.Context) {
return func(c *gin.Context) {
authHelper(c, common.RoleRootUser)
}
}
// GetAuthIdentity returns a dashboard session identity. PAT-authenticated
// requests intentionally have no SessionID and cannot manage browser sessions.
func GetAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) {
value, ok := c.Get(authIdentityContextKey)
if !ok {
return service.AuthIdentity{}, false
}
identity, ok := value.(service.AuthIdentity)
return identity, ok
}
// GetSessionAuthIdentity returns only identities backed by a live dashboard
// session. PAT-authenticated requests intentionally fail this check.
func GetSessionAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) {
identity, ok := GetAuthIdentity(c)
if !ok {
identity = service.AuthIdentity{
UserID: c.GetInt("id"),
SessionID: c.GetString("session_id"),
UserAuthVersion: c.GetInt64("auth_version"),
SessionVersion: c.GetInt64("session_version"),
}
}
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 {
return service.AuthIdentity{}, false
}
return identity, true
}
// GetStepUpIdentity returns the identity that may request and consume
// security verification proofs: a browser session, or a scoped access token
// bound to its own pat:<id> session. Legacy access tokens have neither.
func GetStepUpIdentity(c *gin.Context) (service.AuthIdentity, bool) {
if identity, ok := GetSessionAuthIdentity(c); ok {
return identity, true
}
value, ok := c.Get(accessTokenIdentityContextKey)
if !ok {
return service.AuthIdentity{}, false
}
identity, ok := value.(service.AuthIdentity)
return identity, ok && identity.UserID > 0 && identity.SessionID != ""
}
func authenticateDashboardRequest(c *gin.Context) (*model.UserBase, service.AuthIdentity, bool, error) {
user, identity, credentialKind, err := classifyDashboardCredential(c)
if err != nil {
return nil, service.AuthIdentity{}, credentialKind == dashboardCredentialPAT, err
}
if credentialKind == dashboardCredentialUnmatched {
return nil, service.AuthIdentity{}, false, service.ErrAuthTokenInvalid
}
return user, identity, credentialKind == dashboardCredentialPAT, nil
}
func classifyDashboardCredential(c *gin.Context) (*model.UserBase, service.AuthIdentity, dashboardCredentialKind, error) {
raw, ok := authorizationToken(c.GetHeader("Authorization"))
if !ok {
return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil
}
identity, internal, err := service.ParseDashboardAccessToken(raw)
if internal {
if err != nil {
return nil, service.AuthIdentity{}, dashboardCredentialInternal, err
}
_, user, err := service.ValidateLoginSession(identity)
if err != nil {
return nil, service.AuthIdentity{}, dashboardCredentialInternal, err
}
return user, identity, dashboardCredentialInternal, nil
}
lookup := lookupAccessToken(c, raw)
if lookup.user != nil {
beginAccessTokenAudit(c, lookup.user, lookup.ref)
}
if lookup.err != nil {
if errors.Is(lookup.err, model.ErrAccessTokenExpired) {
setAccessTokenAuditParam(c, "failure_reason", "expired")
}
return nil, service.AuthIdentity{}, dashboardCredentialPAT, lookup.err
}
if lookup.user == nil {
return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil
}
return lookup.user, service.AuthIdentity{UserID: lookup.user.Id, UserAuthVersion: lookup.user.AuthVersion}, dashboardCredentialPAT, nil
}
// accessTokenLookup is the per-request result of resolving an opaque dashboard
// credential. A nil user with a nil err means the value is not a PAT.
type accessTokenLookup struct {
ref string
legacy bool
token *model.UserAccessToken
user *model.UserBase
err error
}
// lookupAccessToken resolves raw once per request, so the access audit and
// authentication share one database lookup.
func lookupAccessToken(c *gin.Context, raw string) *accessTokenLookup {
ref := model.AccessTokenFingerprint(raw)
if cached := requestAccessTokenLookup(c); cached != nil && cached.ref == ref {
return cached
}
lookup := &accessTokenLookup{ref: ref}
c.Set(accessTokenLookupContextKey, lookup)
if !strings.HasPrefix(raw, model.AccessTokenPrefix) {
lookup.legacy = true
legacyUser, err := model.ValidateAccessToken(raw)
if err != nil || legacyUser == nil || legacyUser.Id <= 0 {
lookup.err = err
return lookup
}
lookup.user, lookup.err = model.GetUserCache(legacyUser.Id)
return lookup
}
token, err := model.FindUserAccessTokenByHash(ref)
if err != nil || token == nil {
lookup.err = err
return lookup
}
lookup.token = token
lookup.user, lookup.err = model.GetUserCache(token.UserId)
if lookup.err == nil && token.Expired(common.GetTimestamp()) {
// Keep the owner so the rejected attempt is still audited.
lookup.err = model.ErrAccessTokenExpired
}
return lookup
}
func requestAccessTokenLookup(c *gin.Context) *accessTokenLookup {
value, _ := c.Get(accessTokenLookupContextKey)
lookup, _ := value.(*accessTokenLookup)
return lookup
}
// enforceAccessTokenRoute applies the route's access token rule (fail closed:
// scoped tokens cannot call undeclared routes). With abort set it writes the
// 403 response and records the reason in the access audit.
func enforceAccessTokenRoute(c *gin.Context, lookup *accessTokenLookup, abort bool) bool {
key := c.Request.Method + " " + c.FullPath()
rule, declared := AccessTokenRouteRule(key)
code, reason := "", ""
var message *common.Message
switch {
case declared && rule.kind == accessTokenRuleSession:
code, reason, message = "AUTH_SESSION_REQUIRED", "session_required", common.NewMessage(accessTokenSessionRequiredMsg)
case lookup.legacy:
return true
case !declared:
common.SysError(common.LogText("access token route is not declared: %s", key))
code, reason, message = "ACCESS_TOKEN_ROUTE_UNDECLARED", "route_undeclared", common.NewMessage("Unauthorized, insufficient privileges")
case rule.kind == accessTokenRuleAny, service.AccessTokenScopeGranted(lookup.token.GetScopes(), rule.scope):
return true
default:
code, reason, message = "ACCESS_TOKEN_SCOPE_DENIED", "scope_denied", common.NewMessage("The access token lacks the required permission: {{scope}}", map[string]any{"scope": rule.scope})
if abort {
setAccessTokenAuditParam(c, "required_scope", rule.scope)
}
}
if abort {
setAccessTokenAuditParam(c, "failure_reason", reason)
abortWithWebMessage(c, http.StatusForbidden, code, message)
}
return false
}
// setAccessTokenContext exposes the token grant to handlers. Scoped tokens
// also get a step-up identity bound to the token instead of a browser session.
func setAccessTokenContext(c *gin.Context, lookup *accessTokenLookup) {
_, alreadySet := c.Get(accessTokenIdentityContextKey)
c.Set("access_token_legacy", lookup.legacy)
// Security audit params identify the token the same way the access audit does.
c.Set("access_token_ref", lookup.ref)
if lookup.legacy {
c.Set("access_token_id", 0)
c.Set("access_token_scopes", []string{})
return
}
c.Set("access_token_id", lookup.token.Id)
c.Set("access_token_scopes", lookup.token.GetScopes())
c.Set(accessTokenIdentityContextKey, service.AuthIdentity{
UserID: lookup.user.Id,
SessionID: model.AccessTokenSessionID(lookup.token.Id),
UserAuthVersion: lookup.user.AuthVersion,
SessionVersion: model.AccessTokenSessionVersion,
})
if alreadySet {
return
}
if err := model.TouchUserAccessToken(lookup.token.Id, c.ClientIP(), common.GetTimestamp()); err != nil {
logger.LogError(c.Request.Context(), common.LogText("access token last-use update failed (token_id=%d): %v", lookup.token.Id, err))
}
}
func authorizationToken(header string) (string, bool) {
header = strings.TrimSpace(header)
if header == "" {
return "", false
}
parts := strings.Fields(header)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
header = parts[1]
} else if len(parts) != 1 {
return "", false
}
return header, header != ""
}
func setDashboardAuthContext(c *gin.Context, user *model.UserBase, identity service.AuthIdentity, useAccessToken bool) {
c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf")
c.Set("username", user.Username)
c.Set("role", user.Role)
c.Set("id", user.Id)
c.Set("group", user.Group)
c.Set("user_group", user.Group)
c.Set("use_access_token", useAccessToken)
c.Set("session_id", identity.SessionID)
c.Set("auth_version", identity.UserAuthVersion)
c.Set("session_version", identity.SessionVersion)
c.Set(authIdentityContextKey, identity)
user.WriteContext(c)
}
// abortWithWebMessage answers a web console request; the web console
// translates the message, and code, when set, stays a stable error code.
func abortWithWebMessage(c *gin.Context, status int, code string, message *common.Message) {
var fields []gin.H
if code != "" {
fields = append(fields, gin.H{"code": code})
}
common.ApiErrorStatus(c, status, message, fields...)
c.Abort()
}
func writeDashboardAuthError(c *gin.Context, err error) {
if errors.Is(err, model.ErrAccessTokenExpired) {
abortWithWebMessage(c, http.StatusUnauthorized, "ACCESS_TOKEN_EXPIRED", common.NewMessage("Unauthorized, the access token has expired"))
return
}
if errors.Is(err, model.ErrLegacyAccessTokenRetired) {
abortWithWebMessage(c, http.StatusUnauthorized, "ACCESS_TOKEN_LEGACY_RETIRED", common.NewMessage("Legacy access tokens have been retired. Create a new access token in security settings"))
return
}
if errors.Is(err, service.ErrAuthTokenExpired) {
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_TOKEN_EXPIRED", common.NewMessage("Unauthorized, not logged in and no access token provided"))
return
}
if errors.Is(err, service.ErrLoginSessionRevoked) || errors.Is(err, gorm.ErrRecordNotFound) {
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_SESSION_REVOKED", common.NewMessage("Unauthorized, not logged in and no access token provided"))
return
}
if errors.Is(err, service.ErrAuthTokenInvalid) {
abortWithWebMessage(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", common.NewMessage("Unauthorized, invalid access token"))
return
}
common.SysLog(common.LogText("dashboard authentication error: %s", err.Error()))
abortWithWebMessage(c, http.StatusInternalServerError, "AUTH_INTERNAL_ERROR", common.NewMessage("Database error, please contact the administrator"))
}
func RequirePermission(permission authz.Permission) func(c *gin.Context) {
return func(c *gin.Context) {
role := c.GetInt("role")
userID := c.GetInt("id")
if authz.Can(userID, role, permission) {
c.Next()
return
}
abortWithWebMessage(c, http.StatusForbidden, "", common.NewMessage("Unauthorized, insufficient privileges"))
}
}
func WssAuth(c *gin.Context) {
}
// TokenOrUserAuth allows either session-based user auth or API token auth.
// Used for endpoints that need to be accessible from both the dashboard and API clients.
func TokenOrUserAuth() func(c *gin.Context) {
return func(c *gin.Context) {
raw, ok := authorizationToken(c.GetHeader("Authorization"))
if ok {
identity, internal, err := service.ParseDashboardAccessToken(raw)
if !internal {
TokenAuth()(c)
return
}
if err != nil {
writeDashboardAuthError(c, err)
return
}
_, user, err := service.ValidateLoginSession(identity)
if err != nil {
writeDashboardAuthError(c, err)
return
}
setDashboardAuthContext(c, user, identity, false)
c.Next()
return
}
// Opaque credentials are relay API keys here, never dashboard PATs.
TokenAuth()(c)
}
}
// TokenAuthReadOnly 宽松版本的令牌认证中间件,用于只读查询接口。
// 只验证令牌 key 是否存在,不检查令牌状态、过期时间和额度。
// 即使令牌已过期、已耗尽或已禁用,也允许访问。
// 仍然检查用户是否被封禁。
func TokenAuthReadOnly() func(c *gin.Context) {
return func(c *gin.Context) {
key := c.Request.Header.Get("Authorization")
if key == "" {
common.ApiErrorStatus(c, http.StatusUnauthorized, errors.New(common.TranslateMessage(c, i18n.MsgTokenNotProvided)))
c.Abort()
return
}
if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") {
key = strings.TrimSpace(key[7:])
}
key = strings.TrimPrefix(key, "sk-")
parts := strings.Split(key, "-")
key = parts[0]
token, err := model.GetTokenByKey(key, false)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorStatus(c, http.StatusUnauthorized, errors.New(common.TranslateMessage(c, i18n.MsgTokenInvalid)))
} else {
common.SysLog(common.LogText("TokenAuthReadOnly GetTokenByKey database error: %s", err.Error()))
common.ApiErrorStatus(c, http.StatusInternalServerError, errors.New(common.TranslateMessage(c, i18n.MsgDatabaseError)))
}
c.Abort()
return
}
// TokenAuthReadOnly must keep allowing other token states to query read-only
// data, such as token usage logs; only explicitly disabled tokens are denied.
if token.Status == common.TokenStatusDisabled {
common.ApiErrorStatus(c, http.StatusUnauthorized, errors.New(common.TranslateMessage(c, i18n.MsgTokenStatusUnavailable)))
c.Abort()
return
}
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
common.SysLog(common.LogText("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
common.ApiErrorStatus(c, http.StatusInternalServerError, errors.New(common.TranslateMessage(c, i18n.MsgDatabaseError)))
c.Abort()
return
}
if userCache.Status != common.UserStatusEnabled {
common.ApiErrorStatus(c, http.StatusForbidden, errors.New(common.TranslateMessage(c, i18n.MsgAuthUserBanned)))
c.Abort()
return
}
c.Set("id", token.UserId)
c.Set("token_id", token.Id)
c.Set("token_key", token.Key)
c.Next()
}
}
func TokenAuth() func(c *gin.Context) {
return func(c *gin.Context) {
// 先检测是否为ws
applyWebSocketSubprotocolAuthorization(c.Request.Header)
// 检查path包含/v1/messages 或 /v1/models
if strings.Contains(c.Request.URL.Path, "/v1/messages") || strings.Contains(c.Request.URL.Path, "/v1/models") {
anthropicKey := c.Request.Header.Get("x-api-key")
if anthropicKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+anthropicKey)
}
}
// gemini api 从query中获取key
if c.Request.URL.Path == "/v1/models" ||
strings.HasPrefix(c.Request.URL.Path, "/v1beta/models") ||
strings.HasPrefix(c.Request.URL.Path, "/v1beta/openai/models") ||
strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
skKey := c.Query("key")
if skKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+skKey)
}
// 从x-goog-api-key header中获取key
xGoogKey := c.Request.Header.Get("x-goog-api-key")
if xGoogKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+xGoogKey)
}
}
key := c.Request.Header.Get("Authorization")
parts := make([]string, 0)
if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") {
key = strings.TrimSpace(key[7:])
}
if key == "" || key == "midjourney-proxy" {
key = c.Request.Header.Get("mj-api-secret")
if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") {
key = strings.TrimSpace(key[7:])
}
key = strings.TrimPrefix(key, "sk-")
parts = strings.Split(key, "-")
key = parts[0]
} else {
key = strings.TrimPrefix(key, "sk-")
parts = strings.Split(key, "-")
key = parts[0]
}
token, err := model.ValidateUserToken(key)
if token != nil {
id := c.GetInt("id")
if id == 0 {
c.Set("id", token.UserId)
}
}
if err != nil {
if errors.Is(err, model.ErrDatabase) {
common.SysLog(common.LogText("TokenAuth ValidateUserToken database error: %s", err.Error()))
abortWithOpenAiMessage(c, http.StatusInternalServerError,
common.TranslateMessage(c, i18n.MsgDatabaseError))
} else {
abortWithOpenAiMessage(c, http.StatusUnauthorized,
common.TranslateMessage(c, i18n.MsgTokenInvalid))
}
return
}
allowIps := token.GetIpLimits()
if len(allowIps) > 0 {
clientIp := c.ClientIP()
logger.LogDebug(c, "Token has IP restrictions, checking client IP %s", clientIp)
ip := net.ParseIP(clientIp)
if ip == nil {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthClientIPUnresolved))
return
}
if common.IsIpInCIDRList(ip, allowIps) == false {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthTokenIPNotAllowed), types.ErrorCodeAccessDenied)
return
}
logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp)
}
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
common.SysLog(common.LogText("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
abortWithOpenAiMessage(c, http.StatusInternalServerError,
common.TranslateMessage(c, i18n.MsgDatabaseError))
return
}
userEnabled := userCache.Status == common.UserStatusEnabled
if !userEnabled {
abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned))
return
}
userCache.WriteContext(c)
userGroup := userCache.Group
tokenGroup := token.Group
if tokenGroup != "" {
// check common.UserUsableGroups[userGroup]
if _, ok := service.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthTokenGroupForbidden, map[string]any{"Group": tokenGroup}))
return
}
// check group in common.GroupRatio
if !ratio_setting.ContainsGroupRatio(tokenGroup) {
if tokenGroup != "auto" {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthTokenGroupDeprecated, map[string]any{"Group": tokenGroup}))
return
}
}
userGroup = tokenGroup
}
common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup)
err = SetupContextForToken(c, token, parts...)
if err != nil {
return
}
c.Next()
}
}
func applyWebSocketSubprotocolAuthorization(header http.Header) bool {
key, ok := apiKeyFromWebSocketSubprotocol(strings.Join(header.Values("Sec-WebSocket-Protocol"), ","))
if !ok {
return false
}
header.Set("Authorization", "Bearer "+key)
return true
}
func apiKeyFromWebSocketSubprotocol(protocols string) (string, bool) {
if protocols == "" {
return "", false
}
const insecureAPIKeyPrefix = "openai-insecure-api-key."
for part := range strings.SplitSeq(protocols, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, insecureAPIKeyPrefix) {
key := strings.TrimPrefix(part, insecureAPIKeyPrefix)
return key, key != ""
}
}
return "", false
}
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
if token == nil {
return fmt.Errorf("token is nil")
}
c.Set("id", token.UserId)
c.Set("token_id", token.Id)
c.Set("token_key", token.Key)
c.Set("token_name", token.Name)
c.Set("token_unlimited_quota", token.UnlimitedQuota)
if !token.UnlimitedQuota {
c.Set("token_quota", token.RemainQuota)
}
if token.ModelLimitsEnabled {
c.Set("token_model_limit_enabled", true)
c.Set("token_model_limit", token.GetModelLimitsMap())
} else {
c.Set("token_model_limit_enabled", false)
}
common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group)
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry)
if token.AutoGroups != "" {
autoGroups, err := token.GetAutoGroups()
if err != nil {
common.SysError(common.LogText("failed to parse auto groups for token %d: %v", token.Id, err))
autoGroups = []string{}
common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
} else if len(autoGroups) > 0 {
common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
}
}
if len(parts) > 1 {
if model.IsAdmin(token.UserId) {
id, err := strconv.Atoi(parts[1])
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
return fmt.Errorf("invalid specific channel id")
}
service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
ChannelId: id,
Source: dto.PinSourceToken,
Rank: dto.PinRankToken,
RetryMode: dto.PinRetrySingleAttempt,
})
} else {
c.Header("specific_channel_version", "701e3ae1dc3f7975556d354e0675168d004891c8")
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgAuthChannelSelectForbidden))
return errors.New("regular users cannot specify a channel")
}
}
return nil
}