diff --git a/.env.example b/.env.example index 5ea44bc28958..104a17a031b4 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,13 @@ # SESSION_SECRET=random_string # 登录密码请求体 RSA-OAEP 加密;默认关闭,且不能替代 HTTPS # PASSWORD_LOGIN_ENCRYPTION_ENABLED=true + +# Account password storage. For a rolling upgrade, deploy every node with bcrypt +# first (dual-format verification), then switch all nodes to argon2id before +# enabling long passwords. A rollback must retain Argon2id verification and +# v2 login-password envelope support (when login encryption is enabled). +# Existing bcrypt hashes and MFA backup codes are not rewritten. +# ACCOUNT_PASSWORD_HASH_ALGORITHM=argon2id # false/未配置:本地 HTTP 模式,关闭 refresh/logout OriginGuard,且不得设置 TRUSTED_URL;兼容本地开发代理。 # true:启用 Secure Refresh Cookie 和严格 OriginGuard,必须同时列出全部可信 HTTPS Origin。 # SESSION_COOKIE_TRUSTED_URL 多项用英文逗号分隔;不支持通配符、路径或域名后缀匹配。 diff --git a/AGENTS.md b/AGENTS.md index 6b518e817ede..8241b5aaaf86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,36 +8,20 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro ## Tech Stack -- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM -- **Frontend**: React 19, TypeScript, Rsbuild, Base UI, Tailwind CSS -- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported) +- **Backend**: Go 1.25.1 (see each module’s `go.mod`), Gin web framework, GORM v2 ORM +- **Frontend**: React 19, TypeScript, Rsbuild 2, TanStack Router/Query/Table, Zustand, Base UI, Tailwind CSS 4 +- **Databases**: SQLite, MySQL, PostgreSQL for the primary database (all three must be supported); a separately configured log database also supports ClickHouse - **Cache**: Redis (go-redis) + in-memory cache -- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.) +- **Auth**: Browser sessions, API tokens and personal access tokens, JWT, WebAuthn/Passkeys, TOTP, OAuth/OIDC; Casbin authorization in `service/authz/` +- **Extensions**: JavaScript task plugins executed by Sobek; Electron desktop wrapper - **Frontend package manager**: Bun (preferred over npm/yarn/pnpm) ## Architecture -Layered architecture: Router -> Controller -> Service -> Model - -``` -router/ — HTTP routing (API, relay, dashboard, web) -controller/ — Request handlers -service/ — Business logic -model/ — Data models and DB access (GORM) -relay/ — AI API relay/proxy with provider adapters - relay/channel/ — Provider-specific adapters (openai/, claude/, gemini/, aws/, etc.) -middleware/ — Auth, rate limiting, CORS, logging, distribution -setting/ — Configuration management (ratio, model, operation, system, performance) -common/ — Shared utilities (JSON, crypto, Redis, env, rate-limit, etc.) -dto/ — Data transfer objects (request/response structs) -constant/ — Constants (API types, channel types, context keys) -types/ — Type definitions (relay formats, file sources, errors) -i18n/ — Backend internationalization (go-i18n, en/zh) -oauth/ — OAuth provider implementations -pkg/ — Internal packages (cachex, ionet) -web/ — Frontend (React 19, Rsbuild, Base UI, Tailwind) - src/i18n/ — Frontend internationalization (i18next, en/zh/zh-TW/fr/ru/ja/vi) -``` +- The Go gateway handles management APIs, upstream relay, billing, and background tasks across `router/`, `middleware/`, `controller/`, `service/`, `model/`, and `relay/`. +- `relaykit/` is an independent Go module for protocol DTOs and conversions; transport, authentication, database access, and billing stay in the host. +- JavaScript task plugins live in `plugins/tasks/`, run through `pkg/jsplugin/`, and integrate with host task polling and settlement. +- `web/` is the React frontend (see `web/AGENTS.md`); `electron/` is the desktop wrapper. ## Internationalization (i18n) @@ -62,14 +46,38 @@ web/ — Frontend (React 19, Rsbuild, Base UI, Tailwind) - A separate function is appropriate when it represents reusable behavior, a required interface/framework callback, an exported API, a test fixture, or complex business logic that deserves direct tests. - If a single-use helper is kept, its name must describe a durable domain concept rather than a mechanical step extracted only to shorten the caller. +### Authentication Security (OWASP Mandatory) + +- Any implementation, modification, or review involving authentication-related flows MUST comply with the applicable requirements of the latest stable [OWASP Application Security Verification Standard (ASVS)](https://owasp.org/www-project-application-security-verification-standard/) and the relevant [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/). This applies to both backend and frontend changes, including registration, login/logout, password changes and recovery, email verification, MFA, WebAuthn/Passkeys, OAuth/OIDC, account linking/unlinking, sessions, JWTs, API credentials, and re-authentication for sensitive actions. +- Before changing these flows, read the applicable OWASP guidance, starting with the [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) and [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html). Consult the password storage, forgot password, MFA, OAuth, and CSRF guidance when those mechanisms are involved. Identify the applicable controls before implementation; existing code is not a justification for retaining or introducing an insecure pattern. +- Enforce security controls on the server. Apply the relevant requirements for credential storage and transport, resistance to account enumeration and brute force, CSRF and replay protection, token/challenge expiry and single use where required, protocol-specific verification, session rotation and invalidation, and re-authentication for sensitive account changes. Frontend checks MUST NOT substitute for server-side enforcement, and recovery or alternative login paths MUST NOT bypass the required authentication assurance. +- Authentication audit events MUST exclude passwords, verification codes, recovery codes, private keys, and usable session or authentication tokens. Record enough non-secret context to investigate authentication failures and sensitive account changes. +- Verify affected security controls with focused regression tests, including applicable failure, expiry, replay, and bypass cases, following the existing backend/frontend test conventions. Record the OWASP references (including the ASVS version and requirement IDs when used), validation performed, and any unresolved gaps in the change summary or PR description. Do not claim compliance or completion while an applicable security requirement remains unmet or unverified. + ### Backend Rules +**Modern Go conventions:** Apply these conventions to new or modified Go code, including tests and `relaykit/`, when they preserve behavior and improve readability. Use the Go version declared in the relevant module's `go.mod` as the compatibility baseline. + +- Use `any` instead of `interface{}`, including map values, slice elements, parameters, and return types. +- For fixed-count loops, prefer `for i := range n`, or `for range n` when the index is unused. For slice indices, prefer `for i := range items`. Keep conventional loops when the bound changes during iteration or the loop needs a different start or step. +- When split results are only traversed once without indexing or reuse, prefer `strings.SplitSeq` or `bytes.SplitSeq` over allocating a slice with `Split`. +- Use `strings.Cut` when splitting at the first separator, and `strings.CutPrefix` / `strings.CutSuffix` when checking and removing a prefix or suffix. Avoid separate searches and manual slicing for the same operation. +- Use `slices.Contains` / `slices.ContainsFunc` for membership checks and `slices.Sort` for natural ordering of ordered element types instead of equivalent hand-written loops or sort callbacks. +- Use `maps.Copy` for shallow map copies and merges. Initialize the destination as needed, and preserve nil-versus-empty behavior and the order in which later values overwrite earlier ones. It does not replace a deep copy. +- Use built-in `min` / `max` for simple bounds instead of equivalent conditional assignments. Preserve numeric semantics; these functions do not prevent overflow in their arguments or replace billing validation and safe quota conversion. +- Use `strings.Builder` for repeated string concatenation in loops; retain direct concatenation for simple fixed expressions. +- Use `reflect.TypeFor[T]()` when the type is known statically, and `reflect.Pointer` instead of `reflect.Ptr`. Keep `reflect.TypeOf` when the dynamic type of a value is required. +- Prefer `sync.WaitGroup.Go` for the standard `Add(1)` / goroutine / deferred `Done()` pattern when its lifecycle and panic contract apply. Preserve existing recovery behavior; the function passed to `Go` must not panic. +- Remove redundant loop-variable copies such as `tc := tc` when they exist only for pre-Go-1.22 closure capture. Retain copies needed for actual snapshot semantics or variables assigned outside the loop. +- Remove ineffective `omitempty` tags on non-pointer struct fields only after confirming the active JSON encoder preserves the same output. Do not change field types or omission behavior as part of a style cleanup; optional relay scalar fields must still follow the pointer rules below. +- Format modified Go files with `gofmt` and remove unused imports after these changes. + **relaykit module independence:** The `relaykit/` Go module MUST remain independently buildable. - Code under `relaykit/` MUST NOT import or depend on packages from the root `new-api` module, or rely on root-only configuration, generated files, or workspace wiring. - Any change affecting `relaykit/` or its public APIs MUST be verified with `cd relaykit && GOWORK=off go build ./...`; a successful root-module build is not sufficient. -**JSON package:** All JSON marshal/unmarshal operations MUST use the wrapper functions in `common/json.go`: +**JSON package:** In the root Go module, all JSON marshal/unmarshal operations MUST use the wrapper functions in `common/json.go`: - `common.Marshal(v any) ([]byte, error)` - `common.Unmarshal(data []byte, v any) error` @@ -79,6 +87,8 @@ web/ — Frontend (React 19, Rsbuild, Base UI, Tailwind) Do NOT directly import or call `encoding/json` in business code. `json.RawMessage`, `json.Number`, and other type definitions from `encoding/json` may still be referenced as types, but actual marshal/unmarshal calls must go through `common.*`. +Inside `relaykit/`, use `kitutil.*` from `relaykit/relayconvert/kitutil/json.go`, never host `common`. Direct encoder calls belong only in codec implementations. + **Database compatibility:** All database code MUST work with SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6 simultaneously. - Any change that can affect database behavior MUST be verified before the work is considered complete. This includes ORM/database-driver dependency changes, connection/DSN/protocol or prepared-statement configuration, models and GORM tags, migrations and `AutoMigrate`, constraints and indexes, `Scanner`/`Valuer`/serializer behavior, raw SQL, transactions, and row locking. @@ -107,6 +117,8 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag **Billing expression system:** When working on tiered/dynamic billing (expression-based pricing), MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language, full architecture, token normalization rules, quota conversion, and expression versioning. All billing expression changes must follow that document. +**Built-in model pricing:** New built-in model prices MUST be defined as self-contained billing expressions in `setting/billing_setting/builtin_billing.go`, using real USD per million tokens. Do not add new built-in prices to the legacy model/completion/cache ratio tables. Preserve explicit administrator pricing overrides. Existing legacy prices are migrated only when explicitly requested. Verify published prices and cover applicable context-length thresholds and cache categories. + **Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth: - Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one. @@ -121,6 +133,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag **Backend test quality:** Backend tests must protect real behavior, API contracts, billing/accounting invariants, data compatibility, or regression paths. +- **Do not scatter tests for a small change:** For a focused feature or fix, extend an existing suitable test file first. If a new test file is necessary, add at most one and consolidate the key regression cases there. MUST NOT create separate test files for the same small feature across `controller/`, `service/`, `setting/`, or other layers merely because its call chain crosses those layers. Do not repeat fixtures and assertions at each layer. Keep the cases compact and focused on observable behavior; the number of production files touched is not a reason to add more test files. - Do not add tests that only improve coverage numbers, prove that code happens to run, or lock in implementation details without a user-visible or cross-module contract. - Avoid fake fuzz/stress/smoke/performance tests built from random inputs, large loop counts, sleeps, timing comparisons, or log-only assertions. - Avoid duplicate tests that exercise the same branch with different names but no new invariant. @@ -132,8 +145,13 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag - Avoid hand-written assertion helpers unless they encode a reusable project-specific invariant. - When cleaning tests, preserve meaningful regression coverage. If a deleted test covered a real contract indirectly, replace it with a smaller test that asserts that contract directly. +**Documentation files:** Do NOT add new files under `docs/` or any of its subdirectories unless the user explicitly requests it. + ### Frontend Rules +- **Reuse existing UI components first (mandatory):** Before implementing or changing frontend UI, read `web/AGENTS.md` and the project `shadcn-ui` skill, search `web/src/components/` and the relevant feature for existing components, and read matching implementations and call sites. Do not start from custom markup or registry installation without checking the repository first. +- Prefer the project's shared business components over lower-level UI primitives when they cover the use case. Evaluate existing props, composition, and a compatible extension before introducing a replacement. Importing `Button` or `AlertDialog` does not satisfy this rule if the same behavior is already provided by a shared component such as `CopyButton` or `ConfirmDialog`. +- New implementations of common UI behavior require a concrete capability gap: identify the existing candidates and explain why reuse, composition, or a compatible extension is unsuitable in the change summary or PR description. Different text, dimensions, colors, or feature location alone do not justify duplication. Feature components may compose shared components with business data and actions. Follow the reuse workflow and component entry points in `web/AGENTS.md`; generic library or registry guidance does not override this project-specific priority. - Use `bun` as the preferred package manager and script runner for the frontend (`web/`): - `bun install` for dependency installation - `bun run dev` for development server diff --git a/common/account_password.go b/common/account_password.go new file mode 100644 index 000000000000..fb7b79cc791c --- /dev/null +++ b/common/account_password.go @@ -0,0 +1,86 @@ +package common + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + "unicode/utf8" + + "golang.org/x/crypto/argon2" +) + +const ( + MinAccountPasswordLength = 8 + MaxAccountPasswordLength = 128 + accountPasswordMemory = 19 * 1024 + accountPasswordTime = 2 + accountPasswordSaltBytes = 16 + accountPasswordKeyBytes = 32 +) + +var ( + ErrAccountPasswordLength = errors.New("Password must contain between 8 and 128 characters.") + ErrAccountPasswordSame = errors.New("New password must be different from current password") + ErrPasswordLegacyLimit = errors.New("Long passwords are unavailable until the password storage upgrade is complete.") +) + +// ValidateNewAccountPassword applies only when a user chooses a new password. +// Authentication must continue to accept historical passwords without applying +// the new policy. Do not normalize passwords, including surrounding whitespace. +func ValidateNewAccountPassword(password string) error { + if !utf8.ValidString(password) || utf8.RuneCountInString(password) < MinAccountPasswordLength || utf8.RuneCountInString(password) > MaxAccountPasswordLength { + return ErrAccountPasswordLength + } + return nil +} + +// HashAccountPassword is for account passwords, not MFA backup codes. The +// temporary bcrypt mode permits rolling out dual-format readers to all nodes +// before enabling Argon2id writes. Existing hashes are never rewritten in bulk. +func HashAccountPassword(password string) (string, error) { + if err := ValidateNewAccountPassword(password); err != nil { + return "", err + } + switch os.Getenv("ACCOUNT_PASSWORD_HASH_ALGORITHM") { + case "bcrypt": + if len(password) > 72 { + return "", ErrPasswordLegacyLimit + } + return Password2Hash(password) + case "", "argon2id": + default: + return "", errors.New("Unsupported account password hashing configuration.") + } + salt := make([]byte, accountPasswordSaltBytes) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate account password salt: %w", err) + } + key := argon2.IDKey([]byte(password), salt, accountPasswordTime, accountPasswordMemory, 1, accountPasswordKeyBytes) + return fmt.Sprintf("$argon2id$v=19$m=19456,t=2,p=1$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil +} + +func validateArgon2AccountPassword(password, encoded string) bool { + // Bound both plaintext and parameters before invoking a memory-hard KDF. + // Only the version/parameters emitted by this application are accepted. + if len(password) > MaxAccountPasswordLength*utf8.UTFMax || len(encoded) > 256 { + return false + } + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=19456,t=2,p=1" { + return false + } + salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) + if err != nil || len(salt) != accountPasswordSaltBytes { + return false + } + expected, err := base64.RawStdEncoding.Strict().DecodeString(parts[5]) + if err != nil || len(expected) != accountPasswordKeyBytes { + return false + } + actual := argon2.IDKey([]byte(password), salt, accountPasswordTime, accountPasswordMemory, 1, accountPasswordKeyBytes) + return subtle.ConstantTimeCompare(actual, expected) == 1 +} diff --git a/common/copy.go b/common/copy.go index 3edb2fa2537e..630af9cc0f69 100644 --- a/common/copy.go +++ b/common/copy.go @@ -1,6 +1,8 @@ package common import ( + "bytes" + "encoding/json" "fmt" "github.com/jinzhu/copier" @@ -11,7 +13,18 @@ func DeepCopy[T any](src *T) (*T, error) { return nil, fmt.Errorf("copy source cannot be nil") } var dst T - err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true}) + err := copier.CopyWithOption(&dst, src, copier.Option{ + DeepCopy: true, IgnoreEmpty: true, + Converters: []copier.TypeConverter{{ + SrcType: json.RawMessage{}, + DstType: json.RawMessage{}, + Fn: func(src any) (any, error) { + // Copy raw JSON in bulk while retaining independent storage for + // request mutation and retries, instead of reflecting over each byte. + return json.RawMessage(bytes.Clone(src.(json.RawMessage))), nil + }, + }}, + }) if err != nil { return nil, err } diff --git a/common/crypto.go b/common/crypto.go index 3ca06bd2d6c3..9adb69ac0fc3 100644 --- a/common/crypto.go +++ b/common/crypto.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "strings" "golang.org/x/crypto/bcrypt" ) @@ -27,6 +28,9 @@ func Password2Hash(password string) (string, error) { } func ValidatePasswordAndHash(password string, hash string) bool { + if strings.HasPrefix(hash, "$argon2id$") { + return validateArgon2AccountPassword(password, hash) + } err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } diff --git a/common/custom-event.go b/common/custom-event.go index fd8fee87cb04..3521293e9b78 100644 --- a/common/custom-event.go +++ b/common/custom-event.go @@ -53,7 +53,7 @@ type CustomEvent struct { Event string Id string Retry uint - Data interface{} + Data any } func encode(writer io.Writer, event CustomEvent) error { @@ -61,7 +61,7 @@ func encode(writer io.Writer, event CustomEvent) error { return writeData(w, event.Data) } -func writeData(w stringWriter, data interface{}) error { +func writeData(w stringWriter, data any) error { dataReplacer.WriteString(w, fmt.Sprint(data)) if strings.HasPrefix(data.(string), "data") { w.writeString("\n\n") diff --git a/common/etag.go b/common/etag.go index cbae5a58654e..9b55e621f34c 100644 --- a/common/etag.go +++ b/common/etag.go @@ -10,7 +10,7 @@ import ( type digestAnchor struct{} func modulePath() string { - return reflect.TypeOf(digestAnchor{}).PkgPath() + return reflect.TypeFor[digestAnchor]().PkgPath() } var digestSeed = func() (s [sha256.Size]byte) { diff --git a/common/gopool.go b/common/gopool.go index d410380b86d1..370a59adb569 100644 --- a/common/gopool.go +++ b/common/gopool.go @@ -12,7 +12,7 @@ var relayGoPool gopool.Pool func init() { relayGoPool = gopool.NewPool("gopool.RelayPool", math.MaxInt32, gopool.NewConfig()) - relayGoPool.SetPanicHandler(func(ctx context.Context, i interface{}) { + relayGoPool.SetPanicHandler(func(ctx context.Context, i any) { if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok { SafeSendBool(stopChan, true) } diff --git a/common/init.go b/common/init.go index d06accec7a49..b34f4376d846 100644 --- a/common/init.go +++ b/common/init.go @@ -190,7 +190,6 @@ func initConstantEnv() { constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false) constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true) constant.TaskPluginEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_ENABLED", true) - constant.TaskPluginOverrideEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_OVERRIDE_ENABLED", true) constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview") constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2) constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10) @@ -214,8 +213,8 @@ func initConstantEnv() { soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "") if soraPatchStr != "" { var taskPricePatches []string - soraPatches := strings.Split(soraPatchStr, ",") - for _, patch := range soraPatches { + soraPatches := strings.SplitSeq(soraPatchStr, ",") + for patch := range soraPatches { trimmedPatch := strings.TrimSpace(patch) if trimmedPatch != "" { taskPricePatches = append(taskPricePatches, trimmedPatch) @@ -227,8 +226,8 @@ func initConstantEnv() { // Initialize trusted redirect domains for URL validation trustedDomainsStr := GetEnvOrDefaultString("TRUSTED_REDIRECT_DOMAINS", "") var trustedDomains []string - domains := strings.Split(trustedDomainsStr, ",") - for _, domain := range domains { + domains := strings.SplitSeq(trustedDomainsStr, ",") + for domain := range domains { trimmedDomain := strings.TrimSpace(domain) if trimmedDomain != "" { // Normalize domain to lowercase diff --git a/common/json.go b/common/json.go index d7effa36ef32..ee095f4756a0 100644 --- a/common/json.go +++ b/common/json.go @@ -4,22 +4,65 @@ import ( "bytes" "encoding/json" "io" + + kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil" + "github.com/gin-gonic/gin/binding" ) -func Unmarshal(data []byte, v any) error { +// hostJSONCodec is the single place where the host chooses its JSON engine. +// Swap the implementation here (for example to sonic.ConfigStd) and every +// common.* and kitutil.* JSON helper, including relaykit DTO (un)marshalling, +// follows. Injected from init() rather than main() so tests run on the same +// engine as production: common is imported by virtually every root package +// and test binary, while main() never executes under `go test`. +type hostJSONCodec struct{} + +func (hostJSONCodec) Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (hostJSONCodec) Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) } +func (hostJSONCodec) Decode(r io.Reader, v any) error { + return json.NewDecoder(r).Decode(v) +} + +func (hostJSONCodec) Valid(data []byte) bool { + return json.Valid(data) +} + +func init() { + kitutil.SetCodec(hostJSONCodec{}) +} + +func Unmarshal(data []byte, v any) error { + return kitutil.Unmarshal(data, v) +} + func UnmarshalJsonStr(data string, v any) error { - return json.Unmarshal(StringToByteSlice(data), v) + return kitutil.UnmarshalJsonStr(data, v) } func DecodeJson(reader io.Reader, v any) error { - return json.NewDecoder(reader).Decode(v) + return kitutil.DecodeJson(reader, v) +} + +// DecodeJsonWithValidation decodes JSON and applies Gin's configured binding-tag +// validator, including binding:"required" and any registered custom validators. +func DecodeJsonWithValidation(reader io.Reader, v any) error { + if err := DecodeJson(reader, v); err != nil { + return err + } + if binding.Validator == nil { + return nil + } + return binding.Validator.ValidateStruct(v) } func Marshal(v any) ([]byte, error) { - return json.Marshal(v) + return kitutil.Marshal(v) } func IndentJson(data []byte) ([]byte, error) { @@ -31,39 +74,10 @@ func IndentJson(data []byte) ([]byte, error) { } func GetJsonType(data json.RawMessage) string { - trimmed := bytes.TrimSpace(data) - if len(trimmed) == 0 { - return "unknown" - } - firstChar := trimmed[0] - switch firstChar { - case '{': - return "object" - case '[': - return "array" - case '"': - return "string" - case 't', 'f': - return "boolean" - case 'n': - return "null" - default: - return "number" - } + return kitutil.GetJsonType(data) } // JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text. func JsonRawMessageToString(data json.RawMessage) string { - trimmed := bytes.TrimSpace(data) - if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { - return "" - } - if trimmed[0] != '"' { - return string(trimmed) - } - var value string - if err := Unmarshal(trimmed, &value); err != nil { - return string(trimmed) - } - return value + return kitutil.JsonRawMessageToString(data) } diff --git a/common/json_test.go b/common/json_test.go index b59949451686..735f741c8f5a 100644 --- a/common/json_test.go +++ b/common/json_test.go @@ -2,9 +2,14 @@ package common import ( "encoding/json" + "strings" "testing" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/go-playground/validator/v10" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestJsonRawMessageToString(t *testing.T) { @@ -41,3 +46,191 @@ func TestJsonRawMessageToString(t *testing.T) { }) } } + +func TestDecodeJsonWithValidation(t *testing.T) { + type request struct { + Code string `json:"code" binding:"required"` + } + for _, test := range []struct { + name, body string + validationError bool + decodeError bool + }{ + {name: "valid", body: `{"code":"123456"}`}, + {name: "missing required field", body: `{}`, validationError: true}, + {name: "empty required field", body: `{"code":""}`, validationError: true}, + {name: "malformed JSON", body: `{"code":`, decodeError: true}, + {name: "wrong field type", body: `{"code":123456}`, decodeError: true}, + } { + t.Run(test.name, func(t *testing.T) { + var value request + err := DecodeJsonWithValidation(strings.NewReader(test.body), &value) + if test.validationError { + var validationErrors validator.ValidationErrors + require.ErrorAs(t, err, &validationErrors) + require.Len(t, validationErrors, 1) + assert.Equal(t, "Code", validationErrors[0].Field()) + assert.Equal(t, "required", validationErrors[0].Tag()) + return + } + if test.decodeError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, "123456", value.Code) + }) + } + // Existing decoding callers opt into validation explicitly. + var unvalidated request + require.NoError(t, DecodeJson(strings.NewReader(`{}`), &unvalidated)) +} + +// TestHostJSONCodecConformance runs through the codec injected by common's +// init() and locks the encoding semantics the relay DTOs depend on. A future +// engine swap in hostJSONCodec must keep every case here green. +func TestHostJSONCodecConformance(t *testing.T) { + type embedded struct { + Content any `json:"content"` + } + type shadowed struct { + embedded + Content any `json:"content,omitempty"` + } + type anyFields struct { + Nil any `json:"nil,omitempty"` + Str any `json:"str,omitempty"` + Int any `json:"int,omitempty"` + Bool any `json:"bool,omitempty"` + } + type rawFields struct { + Obj json.RawMessage `json:"obj"` + Arr json.RawMessage `json:"arr"` + Nested json.RawMessage `json:"nested"` + Str json.RawMessage `json:"str"` + } + type numberField struct { + N json.Number `json:"n"` + } + type pointerZeros struct { + Count *int `json:"count,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + } + zero := 0 + off := false + + t.Run("marshal", func(t *testing.T) { + for _, tt := range []struct { + name string + in any + want string + }{ + { + name: "shallowest field shadows embedded content and nil is omitted", + in: shadowed{embedded: embedded{Content: "inner"}}, + want: `{}`, + }, + { + name: "shallowest field keeps empty string content", + in: shadowed{embedded: embedded{Content: "inner"}, Content: ""}, + want: `{"content":""}`, + }, + { + name: "omitempty on any drops nil but keeps zero values", + in: anyFields{Str: "", Int: 0, Bool: false}, + want: `{"str":"","int":0,"bool":false}`, + }, + { + name: "map keys are sorted", + in: map[string]any{"z": 1, "a": 2, "m": 3}, + want: `{"a":2,"m":3,"z":1}`, + }, + { + name: "html characters are escaped", + in: map[string]string{"s": `&`}, + want: `{"s":"\u003ca href=\"x\"\u003e\u0026\u003c/a\u003e"}`, + }, + { + name: "explicit pointer zeros are kept", + in: pointerZeros{Count: &zero, Enabled: &off}, + want: `{"count":0,"enabled":false}`, + }, + { + name: "nil pointers are omitted", + in: pointerZeros{}, + want: `{}`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + encoded, err := Marshal(tt.in) + require.NoError(t, err) + assert.Equal(t, tt.want, string(encoded)) + }) + } + }) + + t.Run("raw message passthrough", func(t *testing.T) { + input := `{"obj":{},"arr":[],"nested":{"k":[1,2]},"str":"x"}` + var value rawFields + require.NoError(t, UnmarshalJsonStr(input, &value)) + assert.Equal(t, `{}`, string(value.Obj)) + assert.Equal(t, `[]`, string(value.Arr)) + assert.Equal(t, `{"k":[1,2]}`, string(value.Nested)) + assert.Equal(t, `"x"`, string(value.Str)) + encoded, err := Marshal(value) + require.NoError(t, err) + assert.Equal(t, input, string(encoded)) + }) + + t.Run("json.Number keeps large integers exact", func(t *testing.T) { + input := `{"n":18446744073686646784}` + var value numberField + require.NoError(t, Unmarshal([]byte(input), &value)) + assert.Equal(t, json.Number("18446744073686646784"), value.N) + encoded, err := Marshal(value) + require.NoError(t, err) + assert.Equal(t, input, string(encoded)) + }) + + t.Run("explicit zeros survive unmarshal into pointers", func(t *testing.T) { + var value pointerZeros + require.NoError(t, Unmarshal([]byte(`{"count":0,"enabled":false}`), &value)) + require.NotNil(t, value.Count) + require.NotNil(t, value.Enabled) + assert.Equal(t, 0, *value.Count) + assert.False(t, *value.Enabled) + + var absent pointerZeros + require.NoError(t, Unmarshal([]byte(`{}`), &absent)) + assert.Nil(t, absent.Count) + assert.Nil(t, absent.Enabled) + }) + + t.Run("relaykit DTO round trip", func(t *testing.T) { + raw := []byte(`{ + "model":"kimi-k3", + "messages":[ + {"role":"system","tools":[{"type":"function","function":{"name":"get_current_time","description":"Get the current time of a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}, + {"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_current_time","arguments":"{\"city\":\"Beijing\"}"}}]} + ] + }`) + var req dto.GeneralOpenAIRequest + require.NoError(t, Unmarshal(raw, &req)) + encoded, err := Marshal(req) + require.NoError(t, err) + + messages := gjson.GetBytes(encoded, "messages").Array() + require.Len(t, messages, 2) + + // Kimi K3 dynamic tool loading: tools survive and no content key is emitted. + assert.Equal(t, "system", messages[0].Get("role").String()) + assert.JSONEq(t, gjson.GetBytes(raw, "messages.0.tools").Raw, messages[0].Get("tools").Raw) + assert.False(t, messages[0].Get("content").Exists()) + + // Assistant tool-call replay still carries an explicit "content": null. + assistantContent := messages[1].Get("content") + assert.True(t, assistantContent.Exists()) + assert.Equal(t, gjson.Null, assistantContent.Type) + assert.JSONEq(t, gjson.GetBytes(raw, "messages.1.tool_calls").Raw, messages[1].Get("tool_calls").Raw) + }) +} diff --git a/common/password_crypto.go b/common/password_crypto.go index efbb97acbd34..8369c69676e0 100644 --- a/common/password_crypto.go +++ b/common/password_crypto.go @@ -1,6 +1,8 @@ package common import ( + "crypto/aes" + "crypto/cipher" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -12,6 +14,7 @@ import ( "fmt" "strings" "sync" + "unicode/utf8" ) const passwordEncryptionKeyBits = 2048 @@ -92,9 +95,10 @@ func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) { return passwordEncryptionState.keyID, passwordEncryptionState.publicKey } -// DecryptPassword decrypts a base64 RSA-OAEP/SHA-256 password submitted by a -// browser. All malformed inputs share one error so callers do not expose -// cryptographic details to unauthenticated clients. +// DecryptPassword accepts legacy RSA-OAEP/SHA-256 ciphertext and v2 envelopes. +// V2 wraps a fresh AES-256 key with RSA-OAEP and encrypts the password with GCM, +// allowing long Unicode passwords to work with existing 2048-bit server keys. +// Both formats share one public error for all malformed inputs. func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) { passwordEncryptionState.RLock() privateKey := passwordEncryptionState.privateKey @@ -103,6 +107,44 @@ func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) { if privateKey == nil || keyID == "" || keyID != activeKeyID { return "", ErrPasswordEncryptionInvalid } + if strings.HasPrefix(ciphertextBase64, "v2.") { + if len(ciphertextBase64) > 4096 { + return "", ErrPasswordEncryptionInvalid + } + parts := strings.Split(ciphertextBase64, ".") + if len(parts) != 4 { + return "", ErrPasswordEncryptionInvalid + } + wrappedKey, err := base64.StdEncoding.Strict().DecodeString(parts[1]) + if err != nil || len(wrappedKey) != privateKey.Size() { + return "", ErrPasswordEncryptionInvalid + } + nonce, err := base64.StdEncoding.Strict().DecodeString(parts[2]) + if err != nil || len(nonce) != 12 { + return "", ErrPasswordEncryptionInvalid + } + ciphertext, err := base64.StdEncoding.Strict().DecodeString(parts[3]) + if err != nil || len(ciphertext) <= 16 || len(ciphertext) > MaxAccountPasswordLength*utf8.UTFMax+16 { + return "", ErrPasswordEncryptionInvalid + } + key, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, wrappedKey, []byte("password-v2")) + if err != nil || len(key) != 32 { + return "", ErrPasswordEncryptionInvalid + } + block, err := aes.NewCipher(key) + if err != nil { + return "", ErrPasswordEncryptionInvalid + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", ErrPasswordEncryptionInvalid + } + plaintext, err := gcm.Open(nil, nonce, ciphertext, []byte("password-v2:"+keyID)) + if err != nil { + return "", ErrPasswordEncryptionInvalid + } + return string(plaintext), nil + } ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64) if err != nil || len(ciphertext) != privateKey.Size() { return "", ErrPasswordEncryptionInvalid diff --git a/common/quota_math.go b/common/quota_math.go index fb0e71caa612..e658974a9b5f 100644 --- a/common/quota_math.go +++ b/common/quota_math.go @@ -59,11 +59,11 @@ func (c *QuotaClamp) Error() string { // AuditMap renders the clamp as the marker stored under a log's // admin_info.quota_saturation. Centralized here so every billing path (consume // logs, task billing logs, task compensation logs) records the same shape. -func (c *QuotaClamp) AuditMap() map[string]interface{} { +func (c *QuotaClamp) AuditMap() map[string]any { if c == nil { return nil } - return map[string]interface{}{ + return map[string]any{ "op": c.Op, "kind": c.Kind, "original": c.Original, diff --git a/common/redis.go b/common/redis.go index c72878378fce..aef7fc743cc4 100644 --- a/common/redis.go +++ b/common/redis.go @@ -104,13 +104,13 @@ func RedisDelKey(key string) error { return RDB.Del(ctx, key).Err() } -func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { +func RedisHSetObj(key string, obj any, expiration time.Duration) error { if DebugEnabled { SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration)) } ctx := context.Background() - data := make(map[string]interface{}) + data := make(map[string]any) // 使用反射遍历结构体字段 v := reflect.ValueOf(obj).Elem() @@ -125,7 +125,7 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { } // 处理指针类型 - if value.Kind() == reflect.Ptr { + if value.Kind() == reflect.Pointer { if value.IsNil() { data[field.Name] = "" continue @@ -158,7 +158,7 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { return nil } -func RedisHGetObj(key string, obj interface{}) error { +func RedisHGetObj(key string, obj any) error { if DebugEnabled { SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key)) } @@ -175,7 +175,7 @@ func RedisHGetObj(key string, obj interface{}) error { // Handle both pointer and non-pointer values val := reflect.ValueOf(obj) - if val.Kind() != reflect.Ptr { + if val.Kind() != reflect.Pointer { return fmt.Errorf("obj must be a pointer to a struct, got %T", obj) } @@ -192,7 +192,7 @@ func RedisHGetObj(key string, obj interface{}) error { fieldValue := v.Field(i) // Handle pointer types - if fieldValue.Kind() == reflect.Ptr { + if fieldValue.Kind() == reflect.Pointer { if value == "" { continue } @@ -299,7 +299,7 @@ func RedisHIncrBy(key, field string, delta int64) error { return nil } -func RedisHSetField(key, field string, value interface{}) error { +func RedisHSetField(key, field string, value any) error { if DebugEnabled { SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value)) } diff --git a/common/session_cookie.go b/common/session_cookie.go index 28981ce9d817..9f328d4f27fb 100644 --- a/common/session_cookie.go +++ b/common/session_cookie.go @@ -63,8 +63,8 @@ func InitSessionCookieSettings() error { return fmt.Errorf("SESSION_COOKIE_SECURE=true requires SESSION_COOKIE_TRUSTED_URL") } - trustedURLs := strings.Split(trustedURLsRaw, ",") - for _, trustedURL := range trustedURLs { + trustedURLs := strings.SplitSeq(trustedURLsRaw, ",") + for trustedURL := range trustedURLs { trustedURL = strings.TrimSpace(trustedURL) if trustedURL == "" { return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL contains an empty URL") diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index f42e9435dad6..23a951214023 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "net/url" + "slices" "strconv" "strings" ) @@ -197,12 +198,7 @@ func (p *SSRFProtection) isAllowedPort(port int) bool { return true // 如果没有配置端口限制,则允许所有端口 } - for _, allowedPort := range p.AllowedPorts { - if port == allowedPort { - return true - } - } - return false + return slices.Contains(p.AllowedPorts, port) } // isDomainWhitelisted 检查域名是否在白名单中 @@ -222,8 +218,8 @@ func isDomainListed(domain string, list []string) bool { return true } // 通配符匹配 (*.example.com) - if strings.HasPrefix(item, "*.") { - suffix := strings.TrimPrefix(item, "*.") + if after, ok := strings.CutPrefix(item, "*."); ok { + suffix := after if strings.HasSuffix(domain, "."+suffix) || domain == suffix { return true } diff --git a/common/str.go b/common/str.go index b412c7dc1157..cffe228b852a 100644 --- a/common/str.go +++ b/common/str.go @@ -4,11 +4,13 @@ import ( "encoding/base64" "encoding/json" "fmt" - kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil" + "slices" "strconv" "strings" "unsafe" + kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil" + "github.com/samber/lo" ) @@ -36,7 +38,7 @@ func GetRandomString(length int) string { return lo.RandomString(length, lo.AlphanumericCharset) } -func MapToJsonStr(m map[string]interface{}) string { +func MapToJsonStr(m map[string]any) string { bytes, err := json.Marshal(m) if err != nil { return "" @@ -44,8 +46,8 @@ func MapToJsonStr(m map[string]interface{}) string { return string(bytes) } -func StrToMap(str string) (map[string]interface{}, error) { - m := make(map[string]interface{}) +func StrToMap(str string) (map[string]any, error) { + m := make(map[string]any) err := Unmarshal([]byte(str), &m) if err != nil { return nil, err @@ -53,8 +55,8 @@ func StrToMap(str string) (map[string]interface{}, error) { return m, nil } -func StrToJsonArray(str string) ([]interface{}, error) { - var js []interface{} +func StrToJsonArray(str string) ([]any, error) { + var js []any err := json.Unmarshal([]byte(str), &js) if err != nil { return nil, err @@ -63,12 +65,12 @@ func StrToJsonArray(str string) ([]interface{}, error) { } func IsJsonArray(str string) bool { - var js []interface{} + var js []any return json.Unmarshal([]byte(str), &js) == nil } func IsJsonObject(str string) bool { - var js map[string]interface{} + var js map[string]any return json.Unmarshal([]byte(str), &js) == nil } @@ -81,12 +83,7 @@ func String2Int(str string) int { } func StringsContains(strs []string, str string) bool { - for _, s := range strs { - if s == str { - return true - } - } - return false + return slices.Contains(strs, str) } // StringToByteSlice []byte only read, panic on append @@ -126,14 +123,14 @@ func MaskEmail(email string) string { } // Find the @ symbol - atIndex := strings.Index(email, "@") - if atIndex == -1 { + _, after, ok := strings.Cut(email, "@") + if !ok { // No @ symbol found, return masked return "***masked***" } // Return only the domain part with @ symbol - return "***@" + email[atIndex+1:] + return "***@" + after } // MaskSensitiveInfo moved to the conversion kit (kitutil) because the types diff --git a/common/totp.go b/common/totp.go index 400f9d05c5b3..e772fa223937 100644 --- a/common/totp.go +++ b/common/totp.go @@ -49,7 +49,7 @@ func ValidateTOTPCode(secret, code string) bool { func GenerateBackupCodes() ([]string, error) { codes := make([]string, BackupCodeCount) - for i := 0; i < BackupCodeCount; i++ { + for i := range BackupCodeCount { code, err := generateRandomBackupCode() if err != nil { return nil, err diff --git a/common/utils.go b/common/utils.go index 7e658ff4ea58..4850d85ef3a9 100644 --- a/common/utils.go +++ b/common/utils.go @@ -188,7 +188,7 @@ func Seconds2Time(num int) (time string) { return } -func Interface2String(inter interface{}) string { +func Interface2String(inter any) string { switch inter.(type) { case string: return inter.(string) @@ -208,7 +208,7 @@ func Interface2String(inter interface{}) string { return fmt.Sprintf("%v", inter) } -func UnescapeHTML(x string) interface{} { +func UnescapeHTML(x string) any { return template.HTML(x) } diff --git a/constant/context_key.go b/constant/context_key.go index 93a18ba9af01..994e657513b4 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -74,4 +74,9 @@ const ( // fallback in authHelper (finishAdminAudit) skips its record to avoid // duplicate entries. ContextKeyAuditLogged ContextKey = "audit_logged" + + // ContextKeyTokenAuditParams contains only the API token operation's safe metadata. + ContextKeyTokenAuditParams ContextKey = "token_audit_params" + // ContextKeyTokenAuditSucceeded disambiguates token responses that exceed the audit buffer. + ContextKeyTokenAuditSucceeded ContextKey = "token_audit_succeeded" ) diff --git a/constant/task.go b/constant/task.go index aee856831156..46c57845a1d2 100644 --- a/constant/task.go +++ b/constant/task.go @@ -27,11 +27,6 @@ var legacyTaskActionAliases = map[string]string{ // When disabled, factory and override plugins both stop serving. var TaskPluginEnabled = true -// TaskPluginOverrideEnabled controls whether the database override layer is -// active. When disabled, uploaded plugins are ignored and factory plugins are -// used instead; the factory layer is unaffected. -var TaskPluginOverrideEnabled = true - // NormalizeTaskAction maps persisted legacy action names to the canonical task // action vocabulary. Unknown platform-specific actions pass through unchanged. func NormalizeTaskAction(action string) string { diff --git a/controller/access_token.go b/controller/access_token.go new file mode 100644 index 000000000000..8614115f82b2 --- /dev/null +++ b/controller/access_token.go @@ -0,0 +1,113 @@ +package controller + +import ( + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +func GetAccessTokenStatus(c *gin.Context) { + status, err := model.GetUserAccessTokenStatus(c.GetInt("id")) + if err != nil { + writeSecurityOperationError(c, err) + return + } + common.ApiSuccess(c, status) +} + +func GenerateAccessToken(c *gin.Context) { + if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate}) == nil { + return + } + id := c.GetInt("id") + key, err := common.GenerateRandomKey(29 + common.GetRandomInt(4)) + if err != nil { + writeSecurityOperationError(c, err) + return + } + var existing int64 + if err := model.DB.Model(&model.User{}).Where("access_token = ?", key).Count(&existing).Error; err != nil { + writeSecurityOperationError(c, err) + return + } + if existing != 0 { + common.ApiErrorI18n(c, i18n.MsgUuidDuplicate) + return + } + if err := model.UpdateUserAccessToken(id, key); err != nil { + writeSecurityOperationError(c, err) + return + } + recordUserSecurityAudit(c, id, "access_token.generate", map[string]any{"token_ref": model.AccessTokenFingerprint(key)}) + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": key}) +} + +func RevokeAccessToken(c *gin.Context) { + if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}) == nil { + return + } + ref, err := model.RevokeUserAccessToken(c.GetInt("id")) + if err != nil { + writeSecurityOperationError(c, err) + return + } + if ref != "" { + recordUserSecurityAudit(c, c.GetInt("id"), "access_token.revoke", map[string]any{"token_ref": ref}) + } + common.ApiSuccess(c, nil) +} + +func GetAuditLogs(c *gin.Context) { + page := common.GetPageQuery(c) + if page.Page < 1 || page.PageSize < 1 || page.Page > 100000000 { + common.ApiErrorMsg(c, "Invalid audit pagination") + return + } + filter := model.AuditLogFilter{Username: c.Query("username"), Category: c.Query("category"), TokenRef: c.Query("token_ref"), ExcludeTokenRef: c.Query("exclude_token_ref"), RequestId: c.Query("request_id")} + viewerRole := c.GetInt("role") + if c.FullPath() == "/api/audit/self" { + filter.UserId = c.GetInt("id") + filter.Username = "" + filter.SelfView = true + } + if !model.ValidAuditCategory(filter.Category) || !model.ValidTokenFingerprint(filter.TokenRef) || !model.ValidTokenFingerprint(filter.ExcludeTokenRef) { + common.ApiErrorMsg(c, "Invalid audit filters") + return + } + for name, target := range map[string]*int64{"start_timestamp": &filter.StartTimestamp, "end_timestamp": &filter.EndTimestamp} { + if raw := c.Query(name); raw != "" { + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil || parsed < 0 { + common.ApiErrorMsg(c, "Invalid audit time range") + return + } + *target = parsed + } + } + if filter.EndTimestamp > 0 && filter.EndTimestamp < filter.StartTimestamp { + common.ApiErrorMsg(c, "Invalid audit time range") + return + } + if raw := c.Query("success"); raw != "" { + if raw != "true" && raw != "false" { + common.ApiErrorMsg(c, "Invalid audit result") + return + } + success := raw == "true" + filter.Success = &success + } + logs, total, err := model.GetAuditLogs(filter, page.GetStartIdx(), page.GetPageSize(), viewerRole) + if err != nil { + common.ApiError(c, err) + return + } + page.SetItems(logs) + page.SetTotal(int(total)) + common.ApiSuccess(c, page) +} diff --git a/controller/access_token_audit_test.go b/controller/access_token_audit_test.go new file mode 100644 index 000000000000..cc06aa21cbc0 --- /dev/null +++ b/controller/access_token_audit_test.go @@ -0,0 +1,851 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + sqlmysql "github.com/go-sql-driver/mysql" + "gorm.io/driver/clickhouse" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func setupAccessTokenAudit(t *testing.T) (*model.User, string) { + t.Helper() + previousDB, previousLogDB := model.DB, model.LOG_DB + previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType() + previousRedis := common.RedisEnabled + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{})) + model.DB, model.LOG_DB = db, db + common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite) + common.RedisEnabled = false + previousMaster := common.IsMasterNode + common.IsMasterNode = true + require.NoError(t, authz.Init(db)) + t.Cleanup(func() { + model.DB, model.LOG_DB = previousDB, previousLogDB + common.IsMasterNode = previousMaster + common.SetDatabaseTypes(previousMain, previousLog) + common.RedisEnabled = previousRedis + }) + token := "legacy-opaque-token" + user := &model.User{Username: "audit-owner", Password: "placeholder", Role: common.RoleAdminUser, Status: common.UserStatusEnabled, Group: "default", AccessToken: &token, AuthVersion: 1, AffCode: "audit-owner"} + require.NoError(t, db.Create(user).Error) + return user, token +} + +func auditRequest(router http.Handler, method, path, token string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, strings.NewReader(`{"secret":"body-must-not-be-logged"}`)) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("User-Agent", "audit-test-client") + request.RemoteAddr = "192.0.2.8:4567" + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func TestAccessTokenLifecycleAndLateRequests(t *testing.T) { + user, old := setupAccessTokenAudit(t) + router := gin.New() + router.Use(middleware.RequestId(), middleware.AccessTokenAudit()) + router.GET("/api/user/token/status", middleware.UserAuth(), GetAccessTokenStatus) + router.GET("/api/user/token", middleware.UserAuth(), GenerateAccessToken) + router.POST("/api/user/token", middleware.UserAuth(), GenerateAccessToken) + router.DELETE("/api/user/token", middleware.UserAuth(), RevokeAccessToken) + // Rotate during the handler, after authentication has captured the old PAT. + router.POST("/rotate-in-flight", middleware.UserAuth(), func(c *gin.Context) { + require.NoError(t, model.UpdateUserAccessToken(user.Id, "new-token")) + c.JSON(200, gin.H{"success": true}) + }) + status, err := model.GetUserAccessTokenStatus(user.Id) + require.NoError(t, err) + assert.True(t, status.Exists) + assert.Nil(t, status.CreatedAt) + assert.Nil(t, status.LastUsedAt) + assert.NotContains(t, auditRequest(router, "GET", "/api/user/token/status", old).Body.String(), old) + require.Equal(t, 200, auditRequest(router, "POST", "/rotate-in-flight", old).Code) + status, err = model.GetUserAccessTokenStatus(user.Id) + require.NoError(t, err) + assert.Equal(t, model.AccessTokenFingerprint("new-token"), status.TokenRef) + assert.NotNil(t, status.CreatedAt) + assert.Nil(t, status.LastUsedAt, "in-flight old requests must not mark the new generation as used") + assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", old).Code) + for _, method := range []string{"POST", "GET", "DELETE"} { + response := auditRequest(router, method, "/api/user/token", "new-token") + assert.Equal(t, http.StatusForbidden, response.Code) + assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_INVALID"`) + stored, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.Equal(t, "new-token", stored.GetAccessToken(), "a PAT cannot manage itself without a dashboard verification") + } + _, err = model.RevokeUserAccessToken(user.Id) + require.NoError(t, err) + assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code) + ref, err := model.RevokeUserAccessToken(user.Id) + require.NoError(t, err) + assert.Empty(t, ref, "repeated revocation is idempotent") + status, err = model.GetUserAccessTokenStatus(user.Id) + require.NoError(t, err) + assert.False(t, status.Exists) + assert.Nil(t, status.CreatedAt) + var history []model.AuditLog + require.NoError(t, model.LOG_DB.Find(&history).Error) + require.NotEmpty(t, history) + encoded, err := common.Marshal(history) + require.NoError(t, err) + for _, secret := range []string{old, "new-token", "body-must-not-be-logged", "Authorization"} { + assert.NotContains(t, string(encoded), secret) + } +} + +func TestAccessTokenAuditsResultsAndExcludesBrowserSessions(t *testing.T) { + user, pat := setupAccessTokenAudit(t) + router := gin.New() + router.Use(middleware.RequestId(), middleware.AccessTokenAudit()) + router.NoRoute(func(c *gin.Context) { c.JSON(404, gin.H{"success": false}) }) + router.GET("/public", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) }) + router.GET("/rate-limited", func(c *gin.Context) { c.AbortWithStatusJSON(429, gin.H{"success": false}) }, middleware.UserAuth()) + router.GET("/read/:id", middleware.UserAuth(), func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) }) + router.POST("/write", middleware.AdminAuth(), func(c *gin.Context) { + recordManageAudit(c, "option.update", map[string]any{"key": "safe-setting"}) + c.JSON(200, gin.H{"success": true}) + }) + router.POST("/business-failure", middleware.AdminAuth(), func(c *gin.Context) { c.JSON(200, gin.H{"success": false, "message": "secret-response"}) }) + router.GET("/forbidden", middleware.RootAuth(), func(c *gin.Context) { c.Status(204) }) + cases := []struct { + method, path string + status int + success bool + }{{"GET", "/missing", 404, false}, {"GET", "/public", 200, true}, {"GET", "/rate-limited", 429, false}, {"GET", "/read/sensitive-id?password=secret-query", 200, true}, {"POST", "/write", 200, true}, {"POST", "/business-failure", 200, false}, {"GET", "/forbidden", 403, false}} + for _, tc := range cases { + response := auditRequest(router, tc.method, tc.path, pat) + require.Equal(t, tc.status, response.Code) + var access model.AuditLog + require.NoError(t, model.LOG_DB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryAccessToken).First(&access).Error) + assert.Equal(t, tc.success, access.Success) + assert.Equal(t, tc.status, access.Status) + assert.Equal(t, user.Id, access.UserId) + assert.Equal(t, "192.0.2.8", access.Ip) + assert.Equal(t, "audit-test-client", access.UserAgent) + assert.NotContains(t, access.Route, "sensitive-id") + } + var operationCount int64 + require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Where("category = ?", model.AuditCategoryOperation).Count(&operationCount).Error) + assert.EqualValues(t, 2, operationCount, "manual operation must not be duplicated by fallback") + now := time.Now().Unix() + session := &model.UserSession{SID: "audit-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "refresh-placeholder", LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600} + require.NoError(t, model.CreateUserSession(session)) + jwt, _, err := service.IssueAccessToken(service.AuthIdentity{UserID: user.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1}) + require.NoError(t, err) + assert.Equal(t, 200, auditRequest(router, "GET", "/read/123", jwt).Code) + assert.Equal(t, 401, auditRequest(router, "GET", "/read/123", "unknown-token").Code) + entries, total, err := model.GetAuditLogs(model.AuditLogFilter{Category: model.AuditCategoryAccessToken}, 0, 20, common.RoleRootUser) + require.NoError(t, err) + assert.EqualValues(t, 7, total) + encoded, err := common.Marshal(entries) + require.NoError(t, err) + for _, secret := range []string{pat, jwt, "secret-query", "secret-response", "body-must-not-be-logged", "sensitive-id"} { + assert.NotContains(t, string(encoded), secret) + } +} + +func TestAuditIsolationVisibilityAndFailureContracts(t *testing.T) { + user, pat := setupAccessTokenAudit(t) + metadata := model.AuditOther{ + Op: &model.AuditOperation{Action: "generic"}, + AdminInfo: &model.AuditAdminInfo{AdminID: 1}, + RootInfo: model.AuditFields{"private": "root-only"}, + } + for _, owner := range []int{user.Id, user.Id + 1} { + model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: owner, Username: fmt.Sprint(owner), Category: model.AuditCategorySecurity, Success: false, Other: metadata}) + } + router := gin.New() + router.Use(middleware.RequestId(), middleware.AccessTokenAudit()) + router.GET("/api/audit/self", middleware.UserAuth(), GetAuditLogs) + router.GET("/api/audit", middleware.AdminAuth(), middleware.RequirePermission(authz.AuditRead), GetAuditLogs) + response := auditRequest(router, "GET", "/api/audit/self?username=other&user_id=2&category=security&success=false&page_size=1", pat) + var result struct { + Success bool + Data struct { + Items []model.AuditLog + Total int + } + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success) + require.Equal(t, 1, result.Data.Total) + require.Len(t, result.Data.Items, 1) + assert.Equal(t, user.Id, result.Data.Items[0].UserId) + assert.NotContains(t, response.Body.String(), "admin_info") + assert.NotContains(t, response.Body.String(), "root-only") + var payload struct { + Data struct { + Items []struct { + Other map[string]any `json:"other"` + } + } + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload), "audit metadata must be a JSON object, not an encoded string") + require.Len(t, payload.Data.Items, 1) + assert.Contains(t, payload.Data.Items[0].Other, "op") + require.NoError(t, authz.SetUserPermissions(user.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}})) + response = auditRequest(router, "GET", "/api/audit?category=security", pat) + assert.Contains(t, response.Body.String(), "admin_info") + assert.NotContains(t, response.Body.String(), "root-only") + for _, query := range []string{"success=bad", "category=bad", "token_ref=secret", "start_timestamp=-1", "start_timestamp=2&end_timestamp=1", "p=-1", "page_size=-1"} { + assert.Contains(t, auditRequest(router, "GET", "/api/audit/self?"+query, pat).Body.String(), `"success":false`) + } + require.NoError(t, model.LOG_DB.Callback().Query().Before("gorm:query").Register("audit:fail", func(tx *gorm.DB) { + if tx.Statement.Table == "audit_logs" { + tx.AddError(errors.New("audit store unavailable")) + } + })) + _, err := model.GetUserAccessTokenStatus(user.Id) + require.Error(t, err, "audit query failure must not look like never used") + model.LOG_DB.Callback().Query().Remove("audit:fail") + require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("audit:write-fail", func(tx *gorm.DB) { tx.AddError(errors.New("write unavailable")) })) + require.Error(t, model.UpdateUserAccessToken(user.Id, "replacement")) + _, err = model.RevokeUserAccessToken(user.Id) + require.Error(t, err) + model.DB.Callback().Update().Remove("audit:write-fail") + status, err := model.GetUserAccessTokenStatus(user.Id) + require.NoError(t, err) + assert.Equal(t, model.AccessTokenFingerprint(pat), status.TokenRef) +} + +func TestAuditRoleVisibilityAndPermissions(t *testing.T) { + admin, pat := setupAccessTokenAudit(t) + rootToken := "root-audit-token" + root := &model.User{Username: "root-audit", Role: common.RoleRootUser, Status: common.UserStatusEnabled, AuthVersion: 1, AccessToken: &rootToken, AffCode: "root-audit"} + require.NoError(t, model.DB.Create(root).Error) + metadata := model.AuditOther{ + AdminInfo: &model.AuditAdminInfo{AdminID: 1}, + RootInfo: model.AuditFields{"private": "root-only"}, + } + for i, role := range []int{1, 10, 100, 0, -1, 99} { + model.RecordAuditLog(nil, model.AuditLog{ActorRole: role, UserId: admin.Id, Username: admin.Username, Category: model.AuditCategorySecurity, RequestId: fmt.Sprintf("role-%d", role), CreatedAt: int64(100 + i), Other: metadata}) + } + model.RecordAuditLog(nil, model.AuditLog{ActorRole: 100, UserId: root.Id, Username: root.Username, Category: model.AuditCategorySecurity, RequestId: "root-owned", Other: metadata}) + router := gin.New() + router.Use(middleware.RequestId(), middleware.AccessTokenAudit()) + router.GET("/api/audit", middleware.AdminAuth(), middleware.RequirePermission(authz.AuditRead), GetAuditLogs) + router.GET("/api/audit/self", middleware.UserAuth(), GetAuditLogs) + now := time.Now().Unix() + session := &model.UserSession{SID: "audit-permissions-session", UserID: admin.Id, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "placeholder", LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600} + require.NoError(t, model.CreateUserSession(session)) + jwt, _, err := service.IssueAccessToken(service.AuthIdentity{UserID: admin.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1}) + require.NoError(t, err) + for _, credential := range []string{pat, jwt} { + assert.Equal(t, http.StatusForbidden, auditRequest(router, "GET", "/api/audit", credential).Code) + } + require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error { + return authz.SetUserPermissionsInTx(tx, admin.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}}) + })) + require.NoError(t, authz.ReloadPolicy()) + for _, credential := range []string{pat, jwt} { + for _, endpoint := range []string{"/api/audit", "/api/audit/self"} { + response := auditRequest(router, "GET", endpoint+"?category=security&page_size=1", credential) + assert.Equal(t, http.StatusOK, response.Code) + var result struct { + Data struct { + Total int + Items []model.AuditLog + } + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, 2, result.Data.Total) + require.Len(t, result.Data.Items, 1) + assert.Equal(t, 10, result.Data.Items[0].ActorRole) + assert.NotContains(t, response.Body.String(), "root-only") + for _, filter := range []string{"request_id=role-100", "username=" + root.Username, "request_id=role-0"} { + filtered := auditRequest(router, "GET", endpoint+"?category=security&"+filter, credential) + // self ignores supplied usernames, while still excluding every root row. + if endpoint == "/api/audit/self" && strings.HasPrefix(filter, "username=") { + continue + } + assert.Contains(t, filtered.Body.String(), `"total":0`) + } + } + } + rootSelf := auditRequest(router, "GET", "/api/audit/self?category=security", rootToken) + assert.Contains(t, rootSelf.Body.String(), `"actor_role":100`) + assert.NotContains(t, rootSelf.Body.String(), "admin_info") + rootAll := auditRequest(router, "GET", "/api/audit?category=security", rootToken) + assert.Contains(t, rootAll.Body.String(), `"total":7`) + assert.Contains(t, rootAll.Body.String(), "root-only") + require.NoError(t, authz.SetUserPermissions(admin.Id, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: false}})) + for _, credential := range []string{pat, jwt} { + assert.Equal(t, http.StatusForbidden, auditRequest(router, "GET", "/api/audit", credential).Code) + assert.Equal(t, http.StatusOK, auditRequest(router, "GET", "/api/audit/self", credential).Code) + } +} + +func TestAuditRoleSnapshotSurvivesActorChanges(t *testing.T) { + user, pat := setupAccessTokenAudit(t) + user.Role = common.RoleRootUser + require.NoError(t, model.DB.Model(user).Update("role", user.Role).Error) + router := gin.New() + router.Use(middleware.RequestId(), middleware.AccessTokenAudit()) + router.POST("/change-role", middleware.UserAuth(), func(c *gin.Context) { + recordLoginAudit(user, c) + recordUserSecurityAudit(c, user.Id, "user.security_verify", nil) + recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{999}}, auditOperatorInfo(c)) + require.NoError(t, model.DB.Model(user).Update("role", common.RoleAdminUser).Error) + c.Status(200) + }) + require.Equal(t, http.StatusOK, auditRequest(router, "POST", "/change-role", pat).Code) + entries, total, err := model.GetAuditLogs(model.AuditLogFilter{}, 0, 20, common.RoleAdminUser) + require.NoError(t, err) + assert.Zero(t, total) + assert.Empty(t, entries) + status, err := model.GetUserAccessTokenStatus(user.Id) + require.NoError(t, err) + assert.Nil(t, status.LastUsedAt, "a root request must not leak through the last-use summary after demotion") + require.NoError(t, model.DB.Unscoped().Delete(user).Error) + entries, total, err = model.GetAuditLogs(model.AuditLogFilter{}, 0, 20, common.RoleRootUser) + require.NoError(t, err) + assert.EqualValues(t, 4, total) + for _, entry := range entries { + assert.Equal(t, common.RoleRootUser, entry.ActorRole) + } +} + +func TestSecurityAndOperationEventsUseAuditTable(t *testing.T) { + user, _ := setupAccessTokenAudit(t) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/event", nil) + c.Set("id", user.Id) + c.Set("username", user.Username) + c.Set("role", user.Role) + c.Set(common.RequestIdKey, "correlated-request") + recordLoginAudit(user, c) + for _, action := range []string{"user.passkey_register", "user.passkey_delete", "user.2fa_setup", "user.2fa_enable", "user.2fa_disable_self", "user.2fa_backup_codes", "user.security_verify"} { + recordUserSecurityAudit(c, user.Id, action, nil) + } + recordManageAudit(c, "option.update", map[string]any{"key": "safe"}) + recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{user.Id}}, &model.AuditAdminInfo{AdminID: user.Id}) + for _, typ := range []int{model.LogTypeTopup, model.LogTypeConsume, model.LogTypeRefund, model.LogTypeSystem} { + model.RecordLog(user.Id, typ, "business entry") + } + var audits []model.AuditLog + require.NoError(t, model.LOG_DB.Find(&audits).Error) + assert.Len(t, audits, 10) + for _, entry := range audits { + assert.Equal(t, "correlated-request", entry.RequestId) + assert.Equal(t, user.Role, entry.ActorRole) + } + var logs []model.Log + require.NoError(t, model.LOG_DB.Find(&logs).Error) + assert.Len(t, logs, 4) + _, err := model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100) + require.NoError(t, err) + var count int64 + require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Count(&count).Error) + assert.EqualValues(t, 10, count) +} + +// Released schemas copied from v1.0.0-rc.33; only the Go type names differ. + +type releasedAuditUser struct { + Id int `json:"id"` + Username string `json:"username" gorm:"unique;index" validate:"max=20"` + Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` + OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! + DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` + Role int `json:"role" gorm:"type:int;default:1"` // admin, common + Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled + Email string `json:"email" gorm:"index" validate:"max=50"` + GitHubId string `json:"github_id" gorm:"column:github_id;index"` + DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` + OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` + WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` + TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` + VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! + AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management + Quota int `json:"quota" gorm:"type:int;default:0"` + UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota + RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` + AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"` + AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度 + AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度 + InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` + DeletedAt gorm.DeletedAt `gorm:"index"` + LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` + Setting string `json:"setting" gorm:"type:text;column:setting"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` + StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"` + AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"` + AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"` +} + +func (releasedAuditUser) TableName() string { return "users" } + +type releasedAuditLog struct { + Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"` + UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"` + Type int `json:"type" gorm:"index:idx_created_at_type"` + Content string `json:"content"` + Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` + TokenName string `json:"token_name" gorm:"index;default:''"` + ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` + Quota int `json:"quota" gorm:"default:0"` + PromptTokens int `json:"prompt_tokens" gorm:"default:0"` + CompletionTokens int `json:"completion_tokens" gorm:"default:0"` + UseTime int `json:"use_time" gorm:"default:0"` + IsStream bool `json:"is_stream"` + ChannelId int `json:"channel" gorm:"index"` + ChannelName string `json:"channel_name" gorm:"->"` + TokenId int `json:"token_id" gorm:"default:0;index"` + Group string `json:"group" gorm:"index"` + Ip string `json:"ip" gorm:"index;default:''"` + RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"` + UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` + Other string `json:"other"` +} + +func (releasedAuditLog) TableName() string { return "logs" } + +// External tests create a new database per case on a loopback-only disposable +// instance. They never drop databases or tables supplied through an environment variable. +func newAuditTestDatabase(t *testing.T, kind, dsn string) (*gorm.DB, string) { + t.Helper() + if kind == "sqlite" { + path := t.TempDir() + "/audit.db" + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{}) + require.NoError(t, err) + return db, path + } + require.NotEmpty(t, dsn) + name := fmt.Sprintf("newapi_audit_%d", time.Now().UnixNano()) + var original, isolated gorm.Dialector + var newDSN string + if kind == "mysql" { + config, err := sqlmysql.ParseDSN(dsn) + require.NoError(t, err) + require.Equal(t, "tcp", config.Net) + host, _, err := net.SplitHostPort(config.Addr) + require.NoError(t, err) + require.True(t, net.ParseIP(host).IsLoopback(), "database tests only permit loopback instances") + original = mysql.Open(dsn) + config.DBName = name + newDSN = config.FormatDSN() + isolated = mysql.Open(newDSN) + } else { + parsed, err := url.Parse(dsn) + require.NoError(t, err) + require.True(t, net.ParseIP(parsed.Hostname()).IsLoopback(), "database tests only permit loopback instances") + parsed.Path = "/" + name + newDSN = parsed.String() + if kind == "clickhouse" { + original = clickhouse.Open(dsn) + isolated = clickhouse.Open(newDSN) + } else { + original = postgres.Open(dsn) + isolated = postgres.Open(newDSN) + } + } + admin, err := gorm.Open(original, &gorm.Config{}) + require.NoError(t, err) + // No IF NOT EXISTS: a collision fails before any test data can be written. + createSQL := "CREATE DATABASE " + name + if kind == "mysql" { + createSQL += " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + } + require.NoError(t, admin.Exec(createSQL).Error) + sqlDB, err := admin.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + db, err := gorm.Open(isolated, &gorm.Config{}) + require.NoError(t, err) + t.Logf("isolated database: %s (%s)", name, kind) + t.Cleanup(func() { + connection, err := db.DB() + if err == nil { + _ = connection.Close() + } + }) + return db, newDSN +} + +func verifyAuditRoleStorage(t *testing.T) { + t.Helper() + for i, role := range []int{1, 10, 100, 0, 99} { + model.RecordAuditLog(nil, model.AuditLog{ActorRole: role, UserId: 1, Username: "role-owner", CreatedAt: int64(200 + i), Category: model.AuditCategoryOperation, RequestId: fmt.Sprintf("matrix-role-%d", role)}) + } + filter := model.AuditLogFilter{Category: model.AuditCategoryOperation} + visible, total, err := model.GetAuditLogs(filter, 0, 1, common.RoleAdminUser) + require.NoError(t, err) + assert.EqualValues(t, 2, total) + require.Len(t, visible, 1) + assert.Equal(t, common.RoleAdminUser, visible[0].ActorRole) + visible, total, err = model.GetAuditLogs(filter, 1, 1, common.RoleCommonUser) + require.NoError(t, err) + assert.EqualValues(t, 2, total) + require.Len(t, visible, 1) + assert.Equal(t, common.RoleCommonUser, visible[0].ActorRole) + filter.RequestId = "matrix-role-100" + visible, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleAdminUser) + require.NoError(t, err) + assert.Zero(t, total) + assert.Empty(t, visible) + visible, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleRootUser) + require.NoError(t, err) + assert.EqualValues(t, 1, total) + require.Len(t, visible, 1) + assert.Equal(t, common.RoleRootUser, visible[0].ActorRole) +} + +func verifyAuditJSONStorage(t *testing.T) { + t.Helper() + columns, err := model.LOG_DB.Migrator().ColumnTypes(&model.AuditLog{}) + require.NoError(t, err) + var otherType string + for _, column := range columns { + if column.Name() == "other" { + otherType = strings.ToLower(column.DatabaseTypeName()) + } + } + assert.Equal(t, "json", otherType) + + metadata := model.AuditOther{ + Op: &model.AuditOperation{Action: "channel.update", Params: model.AuditFields{ + "id": 42, "name": "渠道", "changed_fields": []string{}, "large_id": uint64(9007199254740993), + "extra": map[string]any{"attempts": 0, "permitted": false, "ratio": 1.25, "targets": []int{1, 2}}, + }}, + AdminInfo: &model.AuditAdminInfo{AdminID: 1}, + AuditInfo: &model.AuditRequestInfo{Method: "PUT", Route: "/api/channel/", Path: "/api/channel/", Status: 200, Success: false}, + RootInfo: model.AuditFields{"private": "root-only"}, + } + model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleCommonUser, UserId: 1, Username: "json-owner", Category: model.AuditCategorySecurity, RequestId: "matrix-json", Other: metadata}) + filter := model.AuditLogFilter{RequestId: "matrix-json"} + entries, total, err := model.GetAuditLogs(filter, 0, 20, common.RoleRootUser) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, entries, 1) + stored, err := common.Marshal(entries[0].Other) + require.NoError(t, err) + expected, err := common.Marshal(metadata) + require.NoError(t, err) + assert.JSONEq(t, string(expected), string(stored)) + require.NotNil(t, entries[0].Other.Op) + assert.Equal(t, "channel.update", entries[0].Other.Op.Action) + require.NotNil(t, entries[0].Other.AuditInfo) + assert.False(t, entries[0].Other.AuditInfo.Success) + var details struct { + Op struct { + Params struct { + LargeId uint64 `json:"large_id"` + } + } + } + require.NoError(t, common.Unmarshal(stored, &details)) + assert.EqualValues(t, 9007199254740993, details.Op.Params.LargeId, "JSON numbers must retain their type and precision") + for _, role := range []int{common.RoleCommonUser, common.RoleAdminUser} { + entries, _, err = model.GetAuditLogs(filter, 0, 20, role) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Nil(t, entries[0].Other.RootInfo) + if role == common.RoleCommonUser { + assert.Nil(t, entries[0].Other.AdminInfo) + assert.Nil(t, entries[0].Other.AuditInfo) + } else { + assert.NotNil(t, entries[0].Other.AdminInfo) + assert.NotNil(t, entries[0].Other.AuditInfo) + } + } + model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleCommonUser, UserId: 1, Username: "json-owner", RequestId: "matrix-json-empty", Other: model.AuditOther{}}) + entries, _, err = model.GetAuditLogs(model.AuditLogFilter{RequestId: "matrix-json-empty"}, 0, 20, common.RoleRootUser) + require.NoError(t, err) + require.Len(t, entries, 1) + empty, err := common.Marshal(entries[0].Other) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(empty)) + for range 2 { + require.NoError(t, model.InitLogDB()) + } + entries, total, err = model.GetAuditLogs(filter, 0, 20, common.RoleRootUser) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, entries, 1) + stored, err = common.Marshal(entries[0].Other) + require.NoError(t, err) + assert.JSONEq(t, string(expected), string(stored), "repeated startup must retain structured audit metadata") +} + +func TestAuditOtherDatabaseEncoding(t *testing.T) { + const payload = `{"op":{"action":"channel.update","params":{"id":9007199254740993,"nested":{"count":0,"enabled":false}}},"admin_info":{"admin_id":1,"admin_username":"root","admin_role":100,"auth_method":"session"},"audit_info":{"method":"PUT","route":"/api/channel/","path":"/api/channel/","status":200,"success":false},"root_info":{"generation":18446744073709551615}}` + for _, input := range []any{payload, []byte(payload)} { + var other model.AuditOther + require.NoError(t, other.Scan(input)) + require.NotNil(t, other.Op) + assert.Equal(t, "channel.update", other.Op.Action) + require.NotNil(t, other.AdminInfo) + assert.Equal(t, 100, other.AdminInfo.AdminRole) + require.NotNil(t, other.AuditInfo) + assert.False(t, other.AuditInfo.Success) + encoded, err := other.Value() + require.NoError(t, err) + text, ok := encoded.(string) + require.True(t, ok, "PostgreSQL simple protocol requires a string parameter") + assert.JSONEq(t, payload, text) + assert.Contains(t, text, "9007199254740993") + assert.Contains(t, text, "18446744073709551615") + for _, empty := range []any{nil, "", []byte(`null`), "{}"} { + require.NoError(t, other.Scan(input)) + require.NoError(t, other.Scan(empty)) + encoded, err = other.Value() + require.NoError(t, err) + assert.Equal(t, "{}", encoded, "empty input must clear fields from the previous row") + } + } + var invalid model.AuditOther + assert.Error(t, invalid.Scan(42)) + assert.Error(t, invalid.Scan([]byte(`{broken`))) +} + +func TestAuditDatabaseMatrix(t *testing.T) { + previousDB, previousLogDB := model.DB, model.LOG_DB + previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType() + previousRedis := common.RedisEnabled + previousMaster, previousSQLite := common.IsMasterNode, common.SQLitePath + common.IsMasterNode = true + common.RedisEnabled = false + t.Cleanup(func() { + common.IsMasterNode, common.SQLitePath = previousMaster, previousSQLite + model.DB, model.LOG_DB = previousDB, previousLogDB + common.SetDatabaseTypes(previousMain, previousLog) + common.RedisEnabled = previousRedis + }) + cases := []struct { + name, env string + typ common.DatabaseType + }{ + {"sqlite", "", common.DatabaseTypeSQLite}, {"mysql", "AUDIT_MYSQL_DSN", common.DatabaseTypeMySQL}, {"postgres", "AUDIT_POSTGRES_DSN", common.DatabaseTypePostgreSQL}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dsn := os.Getenv(tc.env) + if tc.env != "" && dsn == "" { + t.Skip(tc.env + " is not configured") + } + for _, upgrade := range []bool{false, true} { + t.Run(fmt.Sprintf("upgrade=%v", upgrade), func(t *testing.T) { + db, isolatedDSN := newAuditTestDatabase(t, tc.name, dsn) + t.Setenv("LOG_SQL_DSN", "") + if tc.name == "sqlite" { + common.SQLitePath = isolatedDSN + t.Setenv("SQL_DSN", "local") + } else { + t.Setenv("SQL_DSN", isolatedDSN) + } + model.DB, model.LOG_DB = db, db + common.SetDatabaseTypes(tc.typ, tc.typ) + versionSQL := "SELECT version()" + if tc.name == "sqlite" { + versionSQL = "SELECT sqlite_version()" + } + var version string + require.NoError(t, db.Raw(versionSQL).Scan(&version).Error) + t.Logf("database version: %s", version) + if upgrade { + require.NoError(t, db.AutoMigrate(&releasedAuditUser{}, &releasedAuditLog{})) + legacy := "released-token" + require.NoError(t, db.Create(&releasedAuditUser{Username: "released-owner", Password: "placeholder", AccessToken: &legacy, AffCode: "released-aff", Quota: 1234}).Error) + require.NoError(t, db.Create(&releasedAuditLog{UserId: 1, Type: model.LogTypeLogin, Content: "historical login", CreatedAt: 100, RequestId: "legacy-request"}).Error) + } + for range 2 { + require.NoError(t, model.InitDB()) + require.NoError(t, model.InitLogDB()) + } + if !upgrade { + require.NoError(t, db.Create(&model.User{Username: "fresh-owner", Password: "placeholder", AffCode: "fresh-aff"}).Error) + } + status, err := model.GetUserAccessTokenStatus(1) + require.NoError(t, err) + assert.Equal(t, upgrade, status.Exists) + assert.Nil(t, status.CreatedAt) + if upgrade { + assert.Equal(t, model.AccessTokenFingerprint("released-token"), status.TokenRef) + legacyUser, validationErr := model.ValidateAccessToken("released-token") + require.NoError(t, validationErr) + require.NotNil(t, legacyUser) + var user model.User + require.NoError(t, db.First(&user, 1).Error) + assert.Equal(t, 1234, user.Quota) + var old model.Log + require.NoError(t, db.First(&old).Error) + assert.Equal(t, "historical login", old.Content) + } + require.NoError(t, model.UpdateUserAccessToken(1, "matrix-token")) + require.Error(t, db.Create(&model.User{Username: "duplicate", Password: "placeholder", AffCode: "duplicate-aff", AccessToken: common.GetPointer("matrix-token")}).Error, "PAT uniqueness must survive upgrade") + for _, timestamp := range []int64{101, 102, 103} { + model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: timestamp, Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("matrix-token"), Ip: "192.0.2.1", Success: timestamp != 102}) + } + first, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 2, common.RoleCommonUser) + require.NoError(t, err) + assert.EqualValues(t, 3, total) + require.Len(t, first, 2) + assert.EqualValues(t, 103, first[0].CreatedAt) + second, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 2, 2, common.RoleCommonUser) + require.NoError(t, err) + require.Len(t, second, 1) + assert.EqualValues(t, 101, second[0].CreatedAt) + failure := false + failed, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1, Success: &failure}, 0, 10, 1) + require.NoError(t, err) + require.Len(t, failed, 1) + assert.EqualValues(t, 102, failed[0].CreatedAt) + status, err = model.GetUserAccessTokenStatus(1) + require.NoError(t, err) + require.NotNil(t, status.LastUsedAt) + assert.EqualValues(t, 103, *status.LastUsedAt) + _, err = model.RevokeUserAccessToken(1) + require.NoError(t, err) + _, err = model.RevokeUserAccessToken(1) + require.NoError(t, err) + require.NoError(t, model.MigrateAuditLogs()) + _, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 10, 1) + require.NoError(t, err) + assert.EqualValues(t, 3, total) + verifyAuditRoleStorage(t) + verifyAuditJSONStorage(t) + require.NoError(t, authz.Init(model.DB)) + assert.False(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead)) + require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error { + return authz.SetUserPermissionsInTx(tx, 1, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: true}}) + })) + require.NoError(t, authz.ReloadPolicy()) + assert.True(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead)) + require.NoError(t, authz.Init(model.DB)) + require.NoError(t, authz.Init(model.DB)) + assert.True(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead)) + require.NoError(t, model.DB.Transaction(func(tx *gorm.DB) error { + return authz.SetUserPermissionsInTx(tx, 1, authz.PermissionsMap{authz.ResourceAudit: {authz.ActionRead: false}}) + })) + require.NoError(t, authz.ReloadPolicy()) + assert.False(t, authz.Can(1, common.RoleAdminUser, authz.AuditRead)) + assert.True(t, authz.Can(1, common.RoleRootUser, authz.AuditRead)) + }) + } + }) + } +} + +func TestIndependentAuditLogStores(t *testing.T) { + _, _ = setupAccessTokenAudit(t) + previousMaster := common.IsMasterNode + common.IsMasterNode = true + t.Cleanup(func() { common.IsMasterNode = previousMaster }) + for _, tc := range []struct{ kind, env string }{{"mysql", "AUDIT_MYSQL_DSN"}, {"postgres", "AUDIT_POSTGRES_DSN"}, {"clickhouse", "AUDIT_CLICKHOUSE_DSN"}} { + for _, upgrade := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/upgrade=%v", tc.kind, upgrade), func(t *testing.T) { + dsn := os.Getenv(tc.env) + if dsn == "" { + t.Skip(tc.env + " is not configured") + } + logDB, isolatedDSN := newAuditTestDatabase(t, tc.kind, dsn) + if upgrade { + if tc.kind == "clickhouse" { + require.NoError(t, logDB.Exec(releasedClickHouseLogSchema).Error) + } else { + require.NoError(t, logDB.AutoMigrate(&releasedAuditLog{})) + } + require.NoError(t, logDB.Create(&releasedAuditLog{UserId: 1, Username: "legacy", CreatedAt: time.Now().Unix(), Type: model.LogTypeLogin, Content: "retained historical login", RequestId: "legacy-split-request"}).Error) + } + t.Setenv("LOG_SQL_DSN", isolatedDSN) + t.Setenv("LOG_SQL_CLICKHOUSE_TTL_DAYS", "7") + require.NoError(t, model.InitLogDB()) + require.NoError(t, model.InitLogDB()) + if upgrade { + var old model.Log + require.NoError(t, model.LOG_DB.Where("request_id = ?", "legacy-split-request").Take(&old).Error) + assert.Equal(t, "retained historical login", old.Content) + } + require.NoError(t, model.UpdateUserAccessToken(1, "independent-pat")) + model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "independent", Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("independent-pat"), Ip: "192.0.2.8", Success: false, Status: 403}) + entries, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, common.RoleAdminUser) + require.NoError(t, err) + assert.EqualValues(t, 1, total) + require.Len(t, entries, 1) + assert.False(t, entries[0].Success) + status, err := model.GetUserAccessTokenStatus(1) + require.NoError(t, err) + require.NotNil(t, status.LastUsedAt) + assert.Equal(t, "192.0.2.8", status.LastUsedIp) + model.RecordLog(1, model.LogTypeTopup, "independent business") + _, err = model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100) + require.NoError(t, err) + _, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, 1) + require.NoError(t, err) + assert.EqualValues(t, 1, total) + var mainCount int64 + require.NoError(t, model.DB.Model(&model.AuditLog{}).Count(&mainCount).Error) + assert.Zero(t, mainCount) + verifyAuditRoleStorage(t) + verifyAuditJSONStorage(t) + if tc.kind == "clickhouse" { + var create string + require.NoError(t, model.LOG_DB.Raw("SHOW CREATE TABLE audit_logs").Scan(&create).Error) + assert.NotContains(t, strings.ToUpper(create), "TTL") + require.NoError(t, model.LOG_DB.Raw("SHOW CREATE TABLE logs").Scan(&create).Error) + assert.Contains(t, strings.ToUpper(create), "TTL") + } + }) + } + } +} + +// ClickHouse logs schema from v1.0.0-rc.33. +const releasedClickHouseLogSchema = ` +CREATE TABLE IF NOT EXISTS logs ( + id Int64 DEFAULT 0, + user_id Int32 DEFAULT 0, + created_at Int64 DEFAULT 0, + type Int32 DEFAULT 0, + content String DEFAULT '', + username String DEFAULT '', + token_name String DEFAULT '', + model_name String DEFAULT '', + quota Int32 DEFAULT 0, + prompt_tokens Int32 DEFAULT 0, + completion_tokens Int32 DEFAULT 0, + use_time Int32 DEFAULT 0, + is_stream UInt8 DEFAULT 0, + channel_id Int32 DEFAULT 0, + token_id Int32 DEFAULT 0, + ` + "`group`" + ` String DEFAULT '', + ip String DEFAULT '', + request_id String DEFAULT '', + upstream_request_id String DEFAULT '', + other String DEFAULT '' +) +ENGINE = MergeTree() +PARTITION BY toYYYYMM(toDateTime(created_at)) +ORDER BY (created_at, request_id)` diff --git a/controller/audit.go b/controller/audit.go index d6974b900806..d9c5bc411eea 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -16,19 +16,32 @@ import ( // action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的 // 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。 var auditContentTemplates = map[string]string{ - "user.create": "Created user ${username} (role ${role})", - "user.update": "Updated user ${username} (ID: ${id})", - "user.delete": "Deleted user ${username} (ID: ${id})", - "user.manage": "Performed ${action} on user ${username} (ID: ${id})", - "user.quota_add": "Increased user quota by ${quota}", - "user.quota_subtract": "Decreased user quota by ${quota}", - "user.quota_override": "Overrode user quota from ${from} to ${to}", - "user.binding_clear": "Cleared ${bindingType} binding for user ${username}", - "user.2fa_disable": "Force-disabled two-factor authentication for the user", - "user.passkey_register": "Registered a passkey", - "user.passkey_delete": "Deleted a passkey", - "user.reset_passkey": "Reset the user passkey", - "option.update": "Updated system setting ${key}", + "user.create": "Created user ${username} (role ${role})", + "user.update": "Updated user ${username} (ID: ${id})", + "user.delete": "Deleted user ${username} (ID: ${id})", + "user.account_delete": "Account deletion", + "user.manage": "Performed ${action} on user ${username} (ID: ${id})", + "user.quota_add": "Increased user quota by ${quota}", + "user.quota_subtract": "Decreased user quota by ${quota}", + "user.quota_override": "Overrode user quota from ${from} to ${to}", + "user.binding_clear": "Cleared ${bindingType} binding for user ${username}", + "user.2fa_disable": "Force-disabled two-factor authentication for the user", + "user.passkey_register": "Registered a passkey", + "access_token.generate": "Generated a system access token", + "access_token.revoke": "Revoked the system access token", + "user.2fa_setup": "Started two-factor authentication setup", + "user.2fa_enable": "Enabled two-factor authentication", + "user.2fa_disable_self": "Disabled two-factor authentication", + "user.2fa_backup_codes": "Regenerated two-factor backup codes", + "user.security_verify": "Completed security verification", + "user.password_change": "Account password change", + "user.binding_start": "Account binding request", + "user.binding_bind": "Account binding", + "user.binding_unbind": "Account unlinking", + "user.email_binding_resend": "Email confirmation code resend", + "user.passkey_delete": "Deleted a passkey", + "user.reset_passkey": "Reset the user passkey", + "option.update": "Updated system setting ${key}", "channel.create": "Created channel ${name} (type ${type}, count ${count})", "channel.update": "Updated channel ${name} (ID: ${id})", @@ -45,14 +58,15 @@ var auditContentTemplates = map[string]string{ "channel.upstream_apply": "Applied upstream model changes to channel (ID: ${id})", "channel.upstream_apply_all": "Applied upstream model changes to ${count} channels", - "redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)", + "redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)", + "redemption.delete_batch": "Batch deleted ${count} redemption codes", "subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}", "subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}", } // auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。 -func auditContentEN(action string, params map[string]interface{}) string { +func auditContentEN(action string, params map[string]any) string { tmpl, ok := auditContentTemplates[action] if !ok { return action @@ -66,12 +80,12 @@ func auditContentEN(action string, params map[string]interface{}) string { } // auditOperatorInfo 从上下文构建操作者身份信息(管理员 id/用户名/角色)。 -func auditOperatorInfo(c *gin.Context) map[string]interface{} { - return map[string]interface{}{ - "admin_id": c.GetInt("id"), - "admin_username": c.GetString("username"), - "admin_role": c.GetInt("role"), - "auth_method": auditAuthMethod(c), +func auditOperatorInfo(c *gin.Context) *model.AuditAdminInfo { + return &model.AuditAdminInfo{ + AdminID: c.GetInt("id"), + AdminUsername: c.GetString("username"), + AdminRole: c.GetInt("role"), + AuthMethod: auditAuthMethod(c), } } @@ -90,26 +104,59 @@ func markAuditLogged(c *gin.Context) { // recordManageAudit 记录一条由操作者本人归属的管理/高危审计日志(资源类操作: // 渠道 / 系统设置 / 兑换码等)。content 由 action+params 自动渲染。 -func recordManageAudit(c *gin.Context, action string, params map[string]interface{}) { +func recordManageAudit(c *gin.Context, action string, params map[string]any) { recordManageAuditFor(c, c.GetInt("id"), action, params) } // recordManageAuditFor 记录一条管理审计日志,日志归属于操作者;targetUserId // 只表示被操作用户,用于在结构化参数中保留目标上下文。 -func recordManageAuditFor(c *gin.Context, targetUserId int, action string, params map[string]interface{}) { +func recordManageAuditFor(c *gin.Context, targetUserId int, action string, params map[string]any) { if params == nil { - params = map[string]interface{}{} + params = map[string]any{} } operatorUserId := c.GetInt("id") if _, ok := params["target_user_id"]; !ok && targetUserId > 0 && targetUserId != operatorUserId { params["target_user_id"] = targetUserId } - model.RecordOperationAuditLog(operatorUserId, auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil) + model.RecordOperationAuditLog(operatorUserId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil, c) markAuditLogged(c) } // recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。 // 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。 -func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]interface{}) { - model.RecordOperationAuditLog(userId, auditContentEN(action, params), c.ClientIP(), action, params, nil, nil) +func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]any) { + if code := c.GetString("security_error_code"); code != "" { + if params == nil { + params = map[string]any{} + } + params["code"] = code + } + var auditInfo *model.AuditRequestInfo + if success, ok := params["success"].(bool); ok { + auditInfo = &model.AuditRequestInfo{ + Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(), + Status: c.Writer.Status(), Success: success, + } + } + model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, auditInfo, c) +} + +func tokenAuditParams(c *gin.Context) model.AuditFields { + params, ok := common.GetContextKeyType[model.AuditFields](c, constant.ContextKeyTokenAuditParams) + if !ok { + params = model.AuditFields{} + common.SetContextKey(c, constant.ContextKeyTokenAuditParams, params) + } + return params +} + +func tokenBatchAuditParams(c *gin.Context, ids []int) model.AuditFields { + params := tokenAuditParams(c) + params["total"] = len(ids) + // Bound audit payloads without changing the batch operation's limits. + params["requested_ids"] = append([]int{}, ids[:min(len(ids), 100)]...) + if len(ids) > 100 { + params["requested_ids_truncated"] = true + } + return params } diff --git a/controller/auth_flow_test.go b/controller/auth_flow_test.go index 5917d05374dc..b8c9030c91ef 100644 --- a/controller/auth_flow_test.go +++ b/controller/auth_flow_test.go @@ -2,23 +2,626 @@ package controller import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "errors" + "fmt" "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/fxamacker/cbor/v2" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" ) +func newSecurityLoginPasskey(t *testing.T, userID int) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + credentialID := sha256.Sum256(elliptic.Marshal(key.Curve, key.X, key.Y)) + publicKey, err := cbor.Marshal(map[int]any{1: 2, 3: -7, -1: 1, -2: key.X.FillBytes(make([]byte, 32)), -3: key.Y.FillBytes(make([]byte, 32))}) + require.NoError(t, err) + require.NoError(t, model.DB.Create(&model.PasskeyCredential{ + UserID: userID, CredentialID: base64.StdEncoding.EncodeToString(credentialID[:]), PublicKey: base64.StdEncoding.EncodeToString(publicKey), + UserPresent: true, UserVerified: true, + }).Error) + return key +} + +func beginSecurityLoginPasskey(t *testing.T, parentToken string) (string, string) { + t.Helper() + body, err := common.Marshal(map[string]string{"flow_token": parentToken}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/login/passkey/begin", string(body), "", service.AuthIdentity{}, LoginPasskeyBegin) + var result struct { + Success bool `json:"success"` + Data struct { + FlowToken string `json:"flow_token"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + UserVerification string `json:"userVerification"` + } `json:"publicKey"` + } `json:"options"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification) + return result.Data.FlowToken, result.Data.Options.PublicKey.Challenge +} + +func TestSecurityLoginCodeCompletesOnce(t *testing.T) { + for _, test := range []struct { + path string + backup bool + }{{"/api/user/login/verify", false}, {"/api/user/login/2fa", false}, {"/api/user/login/verify", true}} { + t.Run(fmt.Sprintf("%s/backup=%t", test.path, test.backup), func(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + require.NoError(t, model.DB.Create(factor).Error) + challenge, err := service.StartLoginVerification(user, "password") + require.NoError(t, err) + require.NotNil(t, challenge) + code, err := totp.GenerateCode(factor.Secret, time.Now()) + require.NoError(t, err) + if test.backup { + code = "ABCD-1234" + hash, err := common.HashBackupCode(code) + require.NoError(t, err) + require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error) + } + body, err := common.Marshal(map[string]string{"flow_token": challenge.FlowToken, "code": code}) + require.NoError(t, err) + router := gin.New() + router.POST("/api/user/login/verify", VerifyLogin) + router.POST("/api/user/login/2fa", Verify2FALogin) + for attempt := range 2 { + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest("POST", test.path, strings.NewReader(string(body)))) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, attempt == 0, result.Success, response.Body.String()) + if attempt == 0 { + var bundle service.AuthBundle + require.NoError(t, common.Unmarshal(result.Data, &bundle)) + assert.NotEmpty(t, bundle.AccessToken) + assert.Equal(t, "password", bundle.Session.LoginMethod) + assert.NotEmpty(t, response.Header().Values("Set-Cookie")) + } else { + assert.Empty(t, response.Header().Values("Set-Cookie")) + } + } + count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + assert.EqualValues(t, 2, count) + }) + } +} + +func TestSecurityLoginRejectsChangedOrExpiredAuthorization(t *testing.T) { + for _, change := range []string{"expired", "disabled user", "auth version", "factor removed", "factor locked", "legacy flow", "wrong method", "other purpose"} { + t.Run(change, func(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + require.NoError(t, model.DB.Create(factor).Error) + challenge, err := service.StartLoginVerification(user, "password") + require.NoError(t, err) + method := "2fa" + switch change { + case "expired": + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("expires_at", time.Now().Add(-time.Minute)).Error) + case "disabled user": + require.NoError(t, model.DB.Model(user).Update("status", common.UserStatusDisabled).Error) + case "auth version": + require.NoError(t, model.DB.Model(user).Update("auth_version", user.AuthVersion+1).Error) + case "factor removed": + require.NoError(t, model.DB.Delete(factor).Error) + case "factor locked": + require.NoError(t, model.DB.Model(factor).Update("locked_until", time.Now().Add(time.Minute)).Error) + case "legacy flow": + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("purpose", model.AuthFlowPurposeTwoFALogin).Error) + case "wrong method": + method = "password" + case "other purpose": + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("purpose", model.AuthFlowPurposeSecurityProof).Error) + } + code, err := totp.GenerateCode(factor.Secret, time.Now()) + require.NoError(t, err) + body, err := common.Marshal(map[string]string{"flow_token": challenge.FlowToken, "code": code, "method": method}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/login/verify", string(body), "", service.AuthIdentity{}, VerifyLogin) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.False(t, result.Success, change) + assert.Empty(t, response.Header().Values("Set-Cookie")) + count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + assert.EqualValues(t, 1, count) + }) + } +} + +func TestSecurityLoginPasskeyDoesNotRequireAdditionalTwoFA(t *testing.T) { + for _, direct := range []bool{false, true} { + t.Run(fmt.Sprintf("direct=%t", direct), func(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + key := newSecurityLoginPasskey(t, user.Id) + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error) + for _, verified := range []bool{false, true} { + var flowToken, challenge, parentToken string + if direct { + response := securityEnrollmentRequest("POST", "/api/user/passkey/login/begin", "", "", service.AuthIdentity{}, PasskeyLoginBegin) + var result struct { + Data struct { + FlowToken string `json:"flow_token"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + UserVerification string `json:"userVerification"` + } `json:"publicKey"` + } `json:"options"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification) + flowToken, challenge = result.Data.FlowToken, result.Data.Options.PublicKey.Challenge + } else { + pending, err := service.StartLoginVerification(user, "oauth:github") + require.NoError(t, err) + parentToken = pending.FlowToken + flowToken, challenge = beginSecurityLoginPasskey(t, parentToken) + } + var assertion map[string]any + require.NoError(t, common.Unmarshal(securityPasskeyResponse(t, key, challenge, false, 0, verified), &assertion)) + if direct { + response, ok := assertion["response"].(map[string]any) + require.True(t, ok) + response["userHandle"] = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(user.Id))) + } + payload := map[string]any{"flow_token": flowToken, "credential": assertion} + handler, path := PasskeyLoginFinish, "/api/user/passkey/login/finish" + if !direct { + payload["flow_token"], payload["passkey_flow_token"] = parentToken, flowToken + handler, path = LoginPasskeyFinish, "/api/user/login/passkey/finish" + } + body, err := common.Marshal(payload) + require.NoError(t, err) + router := gin.New() + router.POST(path, handler) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest("POST", path, strings.NewReader(string(body)))) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.Equal(t, verified, result.Success, response.Body.String()) + if verified { + var bundle service.AuthBundle + require.NoError(t, common.Unmarshal(result.Data, &bundle)) + assert.NotEmpty(t, bundle.AccessToken) + } else { + assert.Empty(t, response.Header().Values("Set-Cookie")) + } + } + }) + } +} + +func TestSecurityLoginPasskeyConcurrentCompletionCreatesOneSession(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + key := newSecurityLoginPasskey(t, user.Id) + pending, err := service.StartLoginVerification(user, "password") + require.NoError(t, err) + requests := make([]string, 2) + for index := range requests { + token, challenge := beginSecurityLoginPasskey(t, pending.FlowToken) + body, err := common.Marshal(map[string]any{ + "flow_token": pending.FlowToken, "passkey_flow_token": token, + "credential": securityPasskeyResponse(t, key, challenge, false, 0), + }) + require.NoError(t, err) + requests[index] = string(body) + } + start := make(chan struct{}) + responses := make(chan *httptest.ResponseRecorder, 2) + var workers sync.WaitGroup + for _, body := range requests { + workers.Go(func() { + <-start + responses <- securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", body, "", service.AuthIdentity{}, LoginPasskeyFinish) + }) + } + close(start) + workers.Wait() + close(responses) + successes := 0 + for response := range responses { + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + if result.Success { + successes++ + } + } + assert.Equal(t, 1, successes) + count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + assert.EqualValues(t, 2, count) +} + +func TestSecurityLoginSessionFailureRollsBackChallengeConsumption(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + key := newSecurityLoginPasskey(t, user.Id) + pending, err := service.StartLoginVerification(user, "password") + require.NoError(t, err) + require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("login_session_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "user_sessions" { + tx.AddError(errors.New("private session creation failure")) + } + })) + t.Cleanup(func() { _ = model.DB.Callback().Create().Remove("login_session_failure") }) + for _, fail := range []bool{true, false} { + token, challenge := beginSecurityLoginPasskey(t, pending.FlowToken) + body, err := common.Marshal(map[string]any{"flow_token": pending.FlowToken, "passkey_flow_token": token, "credential": securityPasskeyResponse(t, key, challenge, false, 0)}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", string(body), "", service.AuthIdentity{}, LoginPasskeyFinish) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, !fail, result.Success, response.Body.String()) + if fail { + assert.Empty(t, response.Header().Values("Set-Cookie")) + assert.NotContains(t, response.Body.String(), "private") + _, err = model.GetAuthFlow(pending.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeLoginVerification}) + require.NoError(t, err, "a failed session transaction must leave the parent challenge usable") + require.NoError(t, model.DB.Callback().Create().Remove("login_session_failure")) + } + } + count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + assert.EqualValues(t, 2, count) +} + +func TestSecurityLoginFactorStateDoesNotAddPasswordLoginQueries(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + newSecurityLoginPasskey(t, user.Id) + previousPasswordLogin := common.PasswordLoginEnabled + common.PasswordLoginEnabled = true + t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordLogin }) + queries := 0 + require.NoError(t, model.DB.Callback().Query().After("gorm:query").Register("login_query_count", func(tx *gorm.DB) { + if !tx.DryRun { + queries++ + } + })) + t.Cleanup(func() { _ = model.DB.Callback().Query().Remove("login_query_count") }) + state, err := model.GetUserVerificationState(user.Id) + require.NoError(t, err) + assert.True(t, state.HasPassword) + assert.True(t, state.HasPasskey) + assert.False(t, state.HasTwoFA) + assert.Equal(t, 1, queries, "factor availability must be one database round trip") + queries = 0 + response := securityEnrollmentRequest("POST", "/api/user/login", `{"username":"enrollment-user","password":"enrollment-password"}`, "", service.AuthIdentity{}, Login) + assert.Contains(t, response.Body.String(), `"require_verification":true`) + assert.Equal(t, 2, queries, "only the existing credential lookup and the replacement factor-state lookup run before the challenge") +} + +type boundLoginOAuthProvider struct { + authFlowTestOAuthProvider + userID int +} + +func (*boundLoginOAuthProvider) IsUserIDTaken(string) bool { return true } +func (*boundLoginOAuthProvider) ProviderUserIDColumn() string { return "github_id" } +func (provider *boundLoginOAuthProvider) FillUserByProviderID(user *model.User, _ string) error { + return model.DB.First(user, provider.userID).Error +} + +func TestSecurityLoginAllPrimaryTransportsRequireAdditionalVerification(t *testing.T) { + for _, transport := range []string{"oauth", "custom oauth", "wechat", "telegram"} { + t.Run(transport, func(t *testing.T) { + var user *model.User + var telegram *telegramOAuthFixture + if transport == "telegram" { + telegram = setupTelegramOAuthTest(t) + user = telegram.user + } else { + user, _ = setupSecurityEnrollmentTest(t) + } + newSecurityLoginPasskey(t, user.Id) + var response *httptest.ResponseRecorder + switch transport { + case "telegram": + require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error) + state, code := telegram.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(42)) + response = telegramOAuthCallback(state, code, service.AuthIdentity{}) + case "wechat": + require.NoError(t, model.DB.Model(user).Update("wechat_id", "bound-wechat").Error) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "wechat-code", r.URL.Query().Get("code")) + _, _ = w.Write([]byte(`{"success":true,"data":"bound-wechat"}`)) + })) + t.Cleanup(upstream.Close) + previousEnabled, previousAddress := common.WeChatAuthEnabled, common.WeChatServerAddress + common.WeChatAuthEnabled, common.WeChatServerAddress = true, upstream.URL + t.Cleanup(func() { common.WeChatAuthEnabled, common.WeChatServerAddress = previousEnabled, previousAddress }) + response = securityEnrollmentRequest("GET", "/api/oauth/wechat?code=wechat-code", "", "", service.AuthIdentity{}, WeChatAuth) + default: + const slug = "unified-login-test" + if transport == "custom oauth" { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/token" { + _, _ = w.Write([]byte(`{"access_token":"provider-token","token_type":"Bearer"}`)) + return + } + _, _ = w.Write([]byte(`{"sub":"bound-custom","name":"User"}`)) + })) + t.Cleanup(upstream.Close) + oauth.RegisterCustom(slug, oauth.NewGenericOAuthProvider(&model.CustomOAuthProvider{ + Id: 42, Slug: slug, Name: "Custom login", Enabled: true, ClientId: "client", ClientSecret: "secret", UserIdField: "sub", + TokenEndpoint: upstream.URL + "/token", UserInfoEndpoint: upstream.URL + "/userinfo", + })) + require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "bound-custom"}).Error) + } else { + oauth.Register(slug, &boundLoginOAuthProvider{userID: user.Id}) + } + t.Cleanup(func() { oauth.Unregister(slug) }) + token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{Purpose: model.AuthFlowPurposeOAuth, Provider: slug, Intent: model.AuthFlowIntentLogin, Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute)}) + require.NoError(t, err) + router := gin.New() + router.GET("/api/oauth/:provider", HandleOAuth) + response = httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest("GET", "/api/oauth/"+slug+"?state="+token+"&code=provider-code", nil)) + } + var result struct { + Success bool `json:"success"` + Data service.LoginChallenge `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + assert.True(t, result.Data.RequireVerification) + assert.Equal(t, []service.VerificationMethodOption{{Method: "passkey", Available: true}}, result.Data.Methods) + assert.NotEmpty(t, result.Data.FlowToken) + assert.Empty(t, response.Header().Values("Set-Cookie")) + count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + assert.EqualValues(t, 1, count) + }) + } +} + +func TestSecurityLoginPasskeyCannotCompleteAnotherChallenge(t *testing.T) { + for _, otherUser := range []bool{false, true} { + t.Run(fmt.Sprintf("other-user=%t", otherUser), func(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + key := newSecurityLoginPasskey(t, user.Id) + first, err := service.StartLoginVerification(user, "password") + require.NoError(t, err) + passkeyToken, challenge := beginSecurityLoginPasskey(t, first.FlowToken) + if otherUser { + user = &model.User{Username: "other-login", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-login", AuthVersion: 1} + require.NoError(t, model.DB.Create(user).Error) + newSecurityLoginPasskey(t, user.Id) + } + second, err := service.StartLoginVerification(user, "password") + require.NoError(t, err) + body, err := common.Marshal(map[string]any{"flow_token": second.FlowToken, "passkey_flow_token": passkeyToken, "credential": securityPasskeyResponse(t, key, challenge, false, 0)}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", string(body), "", service.AuthIdentity{}, LoginPasskeyFinish) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.False(t, result.Success) + assert.Empty(t, response.Header().Values("Set-Cookie")) + var count int64 + require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error) + assert.EqualValues(t, 1, count) + }) + } +} + +func TestSecurityLoginPasskeyCanManageTwoFAWithScopedProofs(t *testing.T) { + for _, scope := range []string{service.VerificationScopeTwoFADisable, service.VerificationScopeTwoFABackupCodes} { + t.Run(scope, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + key := newSecurityLoginPasskey(t, user.Id) + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error) + hash, err := common.HashBackupCode("ABCD-1234") + require.NoError(t, err) + require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error) + if scope == service.VerificationScopeTwoFABackupCodes { + _, err := service.VerifySecurityInput(identity, service.VerificationInput{Scope: scope, Method: "2fa", Code: "ABCD-1234"}) + assert.ErrorIs(t, err, service.ErrVerificationFailed) + remaining, err := model.GetUnusedBackupCodeCount(user.Id) + require.NoError(t, err) + assert.Equal(t, 1, remaining, "backup codes cannot authorize generating replacement backup codes") + } + beginBody, err := common.Marshal(map[string]string{"scope": scope}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", string(beginBody), "", identity, PasskeyVerifyBegin) + var started struct { + Success bool `json:"success"` + Data struct { + FlowToken string `json:"flow_token"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + UserVerification string `json:"userVerification"` + } `json:"publicKey"` + } `json:"options"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &started)) + require.True(t, started.Success, response.Body.String()) + assert.Equal(t, "required", started.Data.Options.PublicKey.UserVerification) + finish, err := common.Marshal(passkeyFinishRequest{FlowToken: started.Data.FlowToken, Credential: securityPasskeyResponse(t, key, started.Data.Options.PublicKey.Challenge, false, 0)}) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(finish), "", identity, PasskeyVerifyFinish) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + var proof service.SecurityProof + require.NoError(t, common.Unmarshal(result.Data, &proof)) + handler, path := Disable2FA, "/api/user/2fa/disable" + if scope == service.VerificationScopeTwoFABackupCodes { + handler, path = RegenerateBackupCodes, "/api/user/2fa/backup_codes" + response = securityEnrollmentRequest("POST", "/api/user/2fa/disable", `{}`, proof.ProofToken, identity, Disable2FA) + assert.Contains(t, response.Body.String(), "SECURITY_PROOF_SCOPE_MISMATCH") + } + response = securityEnrollmentRequest("POST", path, `{}`, proof.ProofToken, identity, handler) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + stored, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + assert.Equal(t, identity.UserAuthVersion+1, stored.AuthVersion) + _, err = model.GetPasskeyByUserID(user.Id) + require.NoError(t, err, "managing 2FA must retain the alternative Passkey factor") + factor, err := model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + if scope == service.VerificationScopeTwoFADisable { + assert.Nil(t, factor) + } else { + require.NotNil(t, factor) + assert.True(t, factor.IsEnabled) + assert.Contains(t, string(result.Data), "backup_codes") + } + response = securityEnrollmentRequest("POST", path, `{}`, proof.ProofToken, identity, handler) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.False(t, result.Success, "the proof cannot authorize a second mutation") + }) + } +} + +func TestSecurityLoginRegisteredPasskeyRequiresUserVerification(t *testing.T) { + for _, verified := range []bool{false, true} { + t.Run(fmt.Sprintf("verified=%t", verified), func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}, service.VerificationMethodPassword) + response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin) + var result struct { + Success bool `json:"success"` + Data struct { + FlowToken string `json:"flow_token"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + AuthenticatorSelection struct { + UserVerification string `json:"userVerification"` + } `json:"authenticatorSelection"` + } `json:"publicKey"` + } `json:"options"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + assert.Equal(t, "required", result.Data.Options.PublicKey.AuthenticatorSelection.UserVerification) + body, err := common.Marshal(passkeyFinishRequest{FlowToken: result.Data.FlowToken, Credential: securityPasskeyResponse(t, key, result.Data.Options.PublicKey.Challenge, true, 0, verified)}) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish) + var finished securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &finished)) + assert.Equal(t, verified, finished.Success, response.Body.String()) + _, err = model.GetPasskeyByUserID(user.Id) + if verified { + require.NoError(t, err) + } else { + assert.ErrorIs(t, err, model.ErrPasskeyNotFound) + } + }) + } +} + +func TestSecurityLoginRequiresConfiguredFactors(t *testing.T) { + for _, test := range []struct { + name string + twoFA, passkey bool + locked, disabled bool + methods []service.VerificationMethodOption + unavailable bool + }{ + {name: "password without additional factors"}, + {name: "passkey requires verification", passkey: true, methods: []service.VerificationMethodOption{{Method: "passkey", Available: true}}}, + {name: "twofa requires verification", twoFA: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}}}, + {name: "both factors are alternatives", twoFA: true, passkey: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}, {Method: "passkey", Available: true}}}, + {name: "locked twofa permits passkey", twoFA: true, passkey: true, locked: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: false, Reason: service.ErrVerificationLocked.Error()}, {Method: "passkey", Available: true}}}, + {name: "disabled passkey permits twofa", twoFA: true, passkey: true, disabled: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}, {Method: "passkey", Available: false, Reason: "Passkey authentication is disabled."}}}, + {name: "only passkey disabled blocks password", passkey: true, disabled: true, unavailable: true}, + {name: "both factors unavailable block password", passkey: true, twoFA: true, disabled: true, locked: true, unavailable: true}, + } { + t.Run(test.name, func(t *testing.T) { + user, _ := setupSecurityEnrollmentTest(t) + previousPasswordLogin := common.PasswordLoginEnabled + common.PasswordLoginEnabled = true + t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordLogin }) + if test.passkey { + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "login-key", PublicKey: "public-key"}).Error) + } + if test.twoFA { + factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + if test.locked { + until := time.Now().Add(time.Minute) + factor.LockedUntil = &until + } + require.NoError(t, model.DB.Create(factor).Error) + } + system_setting.GetPasskeySettings().Enabled = !test.disabled + before, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + response := securityEnrollmentRequest(http.MethodPost, "/api/user/login", `{"username":"enrollment-user","password":"enrollment-password"}`, "", service.AuthIdentity{}, Login) + var result struct { + Success bool `json:"success"` + Data struct { + RequireVerification bool `json:"require_verification"` + FlowToken string `json:"flow_token"` + ExpiresAt int64 `json:"expires_at"` + AccessToken string `json:"access_token"` + Methods []service.VerificationMethodOption `json:"methods"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + after, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + if test.unavailable { + assert.False(t, result.Success) + assert.Equal(t, before, after) + assert.Empty(t, response.Header().Values("Set-Cookie")) + return + } + require.True(t, result.Success, response.Body.String()) + if len(test.methods) == 0 { + assert.False(t, result.Data.RequireVerification) + assert.NotEmpty(t, result.Data.AccessToken) + assert.Equal(t, before+1, after) + return + } + assert.True(t, result.Data.RequireVerification) + assert.NotEmpty(t, result.Data.FlowToken) + assert.Greater(t, result.Data.ExpiresAt, time.Now().Unix()) + assert.LessOrEqual(t, result.Data.ExpiresAt, time.Now().Add(5*time.Minute).Unix()) + assert.Equal(t, test.methods, result.Data.Methods) + assert.Empty(t, result.Data.AccessToken) + assert.Empty(t, response.Header().Values("Set-Cookie")) + assert.Equal(t, before, after, "a pending challenge must not create a session") + }) + } +} + type authFlowTestOAuthProvider struct { exchangeErr error userInfoErr error @@ -50,18 +653,21 @@ func (*authFlowTestOAuthProvider) ProviderUserIDColumn() string func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider { t.Helper() - previousDB := model.DB + previousDB, previousLogDB := model.DB, model.LOG_DB + previousRedis := common.RedisEnabled + common.RedisEnabled = false previousType := common.MainDatabaseType() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.AuthFlow{})) - model.DB = db + require.NoError(t, db.AutoMigrate(&model.AuthFlow{}, &model.User{}, &model.UserSession{}, &model.AuditLog{})) + model.DB, model.LOG_DB = db, db common.SetMainDatabaseType(common.DatabaseTypeSQLite) provider := &authFlowTestOAuthProvider{} oauth.Register("auth-flow-test", provider) t.Cleanup(func() { oauth.Unregister("auth-flow-test") - model.DB = previousDB + model.DB, model.LOG_DB = previousDB, previousLogDB + common.RedisEnabled = previousRedis common.SetMainDatabaseType(previousType) }) return provider @@ -97,34 +703,23 @@ func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) { } func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) { - setupAuthFlowControllerTest(t) - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"bind"}`)) - c.Request.Header.Set("Content-Type", "application/json") - c.Set("id", 42) - c.Set("session_id", "session-42") - c.Set("auth_version", int64(3)) - c.Set("session_version", int64(2)) - - GenerateOAuthCode(c) - - require.Equal(t, http.StatusOK, recorder.Code) - var response struct { + _, identity := setupSecurityEnrollmentTest(t) + oauth.Register("auth-flow-test", &authFlowTestOAuthProvider{}) + t.Cleanup(func() { oauth.Unregister("auth-flow-test") }) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword) + response := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode) + var result struct { Success bool `json:"success"` Data struct { FlowToken string `json:"flow_token"` } `json:"data"` } - require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) - require.True(t, response.Success) - flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, - UserId: 42, SessionId: "session-42", - }) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + flow, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, UserId: identity.UserID, SessionId: identity.SessionID}) require.NoError(t, err) - assert.Equal(t, 42, flow.UserId) - assert.Equal(t, "session-42", flow.SessionId) + assert.Equal(t, identity.UserID, flow.UserId) + assert.Equal(t, identity.SessionID, flow.SessionId) } func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) { @@ -198,27 +793,25 @@ func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testin } func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) { - provider := setupAuthFlowControllerTest(t) - flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, - UserId: 42, SessionId: "session-42", Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute), - }) - require.NoError(t, err) - router := gin.New() - router.Use(func(c *gin.Context) { - c.Set("id", 42) - c.Set("session_id", "session-42") - c.Set("auth_version", int64(1)) - c.Set("session_version", int64(1)) - c.Next() + _, identity := setupSecurityEnrollmentTest(t) + provider := &authFlowTestOAuthProvider{} + oauth.Register("auth-flow-test", provider) + t.Cleanup(func() { oauth.Unregister("auth-flow-test") }) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword) + started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode) + var result struct { + Data struct { + FlowToken string `json:"flow_token"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(started.Body.Bytes(), &result)) + require.NotEmpty(t, result.Data.FlowToken) + response := securityEnrollmentRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+result.Data.FlowToken+"&error=access_denied&error_description=cancelled", "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "provider", Value: "auth-flow-test"}} + HandleOAuth(c) }) - router.GET("/api/oauth/:provider", HandleOAuth) - request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+flowToken+"&error=access_denied&error_description=cancelled", nil) - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - assert.Equal(t, http.StatusOK, response.Code) - _, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth}) + _, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth}) assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) assert.Zero(t, provider.exchangeCalls) assert.Zero(t, provider.userInfoCalls) diff --git a/controller/auth_session_test.go b/controller/auth_session_test.go index 670d7ce9ff8f..d4e0a8f4ac17 100644 --- a/controller/auth_session_test.go +++ b/controller/auth_session_test.go @@ -115,7 +115,7 @@ func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) { previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{})) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.PasskeyCredential{})) model.DB = db common.RedisEnabled = false common.UserSessionActiveLimit = 1 diff --git a/controller/billing_option_test.go b/controller/billing_option_test.go index cf6b37ea53f1..529fa6e8e8d1 100644 --- a/controller/billing_option_test.go +++ b/controller/billing_option_test.go @@ -113,7 +113,7 @@ func setupBillingAliasOptionDB(t *testing.T) { previousRedis := common.RedisEnabled database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Option{}, &model.Log{}, &model.User{})) + require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Option{}, &model.Log{}, &model.AuditLog{}, &model.User{})) model.DB = database model.LOG_DB = database common.SetMainDatabaseType(common.DatabaseTypeSQLite) diff --git a/controller/channel-test.go b/controller/channel-test.go index 4d7e4b1f5350..1248853a5164 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -259,7 +259,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError), } } - if err = helper.ApplyReasoningModelSuffix(info, request); err != nil { + if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil { return testResult{ context: c, localErr: err, @@ -613,7 +613,7 @@ func detectErrorFromTestResponseBody(respBody []byte) error { return fmt.Errorf("upstream error: %s", message) } - for _, line := range bytes.Split(b, []byte{'\n'}) { + for line := range bytes.SplitSeq(b, []byte{'\n'}) { line = bytes.TrimSpace(line) if len(line) == 0 { continue @@ -639,7 +639,7 @@ func validateStreamTestResponseBody(respBody []byte) error { return errors.New("stream response body is empty") } - for _, line := range bytes.Split(b, []byte{'\n'}) { + for line := range bytes.SplitSeq(b, []byte{'\n'}) { line = bytes.TrimSpace(line) if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) { continue diff --git a/controller/channel.go b/controller/channel.go index 19ddca8e6a07..b0bde66f61fd 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -3,6 +3,7 @@ package controller import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -368,14 +369,8 @@ func SearchChannels(c *gin.Context) { } total := len(channelData) - startIdx := (page - 1) * pageSize - if startIdx > total { - startIdx = total - } - endIdx := startIdx + pageSize - if endIdx > total { - endIdx = total - } + startIdx := min((page-1)*pageSize, total) + endIdx := min(startIdx+pageSize, total) pagedData := channelData[startIdx:endIdx] @@ -421,25 +416,24 @@ func GetChannel(c *gin.Context) { // 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证 func GetChannelKey(c *gin.Context) { channelId, err := strconv.Atoi(c.Param("id")) - if err != nil { - common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) + if err != nil || channelId <= 0 { + common.ApiErrorMsg(c, "渠道ID格式错误") return } // 获取渠道信息(包含密钥) channel, err := model.GetChannelById(channelId, true) - if err != nil { - common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorI18n(c, i18n.MsgChannelNotExists) return } - - if channel == nil { - common.ApiError(c, fmt.Errorf("渠道不存在")) + if err != nil { + writeSecurityOperationError(c, err) return } // 记录操作审计日志(高危:查看渠道密钥) - recordManageAudit(c, "channel.key_view", map[string]interface{}{ + recordManageAudit(c, "channel.key_view", map[string]any{ "id": channelId, "name": channel.Name, }) @@ -448,29 +442,12 @@ func GetChannelKey(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "获取成功", - "data": map[string]interface{}{ + "data": map[string]any{ "key": channel.Key, }, }) } -// validateTwoFactorAuth 统一的2FA验证函数 -func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool { - // 尝试验证TOTP - if cleanCode, err := common.ValidateNumericCode(code); err == nil { - if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid { - return true - } - } - - // 尝试验证备用码 - if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid { - return true - } - - return false -} - // validateChannel 通用的渠道校验函数 func validateChannel(channel *model.Channel, isAdd bool) error { if channel == nil { @@ -489,11 +466,19 @@ func validateChannel(channel *model.Channel, isAdd bool) error { if len(pluginKey) > 30 { return fmt.Errorf("task plugin key must not exceed 30 characters") } - if _, ok := jsplugin.DefaultRegistry.Get(pluginKey); !ok { + plugin, ok := jsplugin.DefaultRegistry.Get(pluginKey) + if !ok { return fmt.Errorf("task plugin %q is not registered", pluginKey) } if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" { - return fmt.Errorf("base URL is required for task plugin channels") + // The plugin default is persisted onto the channel instead of being + // resolved per request, so the destination host stays an auditable + // channel property that only an administrator edit can change. + if plugin.Meta.BaseURL == "" { + return fmt.Errorf("base URL is required for task plugin channels") + } + defaultBaseURL := plugin.Meta.BaseURL + channel.BaseURL = &defaultBaseURL } } @@ -597,7 +582,7 @@ func getVertexArrayKeys(keys string) ([]string, error) { if keys == "" { return nil, nil } - var keyArray []interface{} + var keyArray []any err := common.Unmarshal([]byte(keys), &keyArray) if err != nil { return nil, fmt.Errorf("批量添加 Vertex AI 必须使用标准的JsonArray格式,例如[{key1}, {key2}...],请检查输入: %w", err) @@ -642,6 +627,9 @@ func AddChannel(c *gin.Context) { return } + baseURLFromPluginDefault := addChannelRequest.Channel != nil && + addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin && + (addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "") // 使用统一的校验函数 if err := validateChannel(addChannelRequest.Channel, true); err != nil { c.JSON(http.StatusOK, gin.H{ @@ -670,7 +658,7 @@ func AddChannel(c *gin.Context) { addChannelRequest.Channel.Key = strings.Join(array, "\n") } else { cleanKeys := make([]string, 0) - for _, key := range strings.Split(addChannelRequest.Channel.Key, "\n") { + for key := range strings.SplitSeq(addChannelRequest.Channel.Key, "\n") { if key == "" { continue } @@ -726,11 +714,15 @@ func AddChannel(c *gin.Context) { common.ApiError(c, err) return } - recordManageAudit(c, "channel.create", map[string]interface{}{ + createAudit := map[string]any{ "name": addChannelRequest.Channel.Name, "type": addChannelRequest.Channel.Type, "count": len(channels), - }) + } + if baseURLFromPluginDefault { + createAudit["base_url_source"] = "plugin_default" + } + recordManageAudit(c, "channel.create", createAudit) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -761,7 +753,7 @@ func DeleteChannel(c *gin.Context) { } else { service.InvalidateProxyClient(channelProxy) } - recordManageAudit(c, "channel.delete", map[string]interface{}{ + recordManageAudit(c, "channel.delete", map[string]any{ "id": id, "name": channelName, }) @@ -782,7 +774,7 @@ func DeleteDisabledChannel(c *gin.Context) { if rows > 0 { service.ResetProxyClientCache() } - recordManageAudit(c, "channel.delete_disabled", map[string]interface{}{ + recordManageAudit(c, "channel.delete_disabled", map[string]any{ "count": rows, }) c.JSON(http.StatusOK, gin.H{ @@ -821,7 +813,7 @@ func DisableTagChannels(c *gin.Context) { return } model.InitChannelCache() - recordManageAudit(c, "channel.tag_disable", map[string]interface{}{ + recordManageAudit(c, "channel.tag_disable", map[string]any{ "tag": channelTag.Tag, }) c.JSON(http.StatusOK, gin.H{ @@ -847,7 +839,7 @@ func EnableTagChannels(c *gin.Context) { return } model.InitChannelCache() - recordManageAudit(c, "channel.tag_enable", map[string]interface{}{ + recordManageAudit(c, "channel.tag_enable", map[string]any{ "tag": channelTag.Tag, }) c.JSON(http.StatusOK, gin.H{ @@ -907,7 +899,7 @@ func EditTagChannels(c *gin.Context) { return } model.InitChannelCache() - recordManageAudit(c, "channel.tag_edit", map[string]interface{}{ + recordManageAudit(c, "channel.tag_edit", map[string]any{ "tag": channelTag.Tag, }) c.JSON(http.StatusOK, gin.H{ @@ -941,7 +933,7 @@ func DeleteChannelBatch(c *gin.Context) { if deletedCount > 0 { service.ResetProxyClientCache() } - recordManageAudit(c, "channel.delete_batch", map[string]interface{}{ + recordManageAudit(c, "channel.delete_batch", map[string]any{ "count": deletedCount, }) c.JSON(http.StatusOK, gin.H{ @@ -998,6 +990,8 @@ func UpdateChannel(c *gin.Context) { return } + baseURLFromPluginDefault := channel.Type == constant.ChannelTypeTaskPlugin && + (channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "") // 使用统一的校验函数 if err := validateChannel(&channel.Channel, false); err != nil { c.JSON(http.StatusOK, gin.H{ @@ -1080,8 +1074,8 @@ func UpdateChannel(c *gin.Context) { } } else { // 普通渠道的处理 - inputKeys := strings.Split(channel.Key, "\n") - for _, key := range inputKeys { + inputKeys := strings.SplitSeq(channel.Key, "\n") + for key := range inputKeys { key = strings.TrimSpace(key) if key != "" { newKeys = append(newKeys, key) @@ -1143,11 +1137,15 @@ func UpdateChannel(c *gin.Context) { if channel.Key != "" && channel.Key != originChannel.Key { changedFields = append(changedFields, "key") } - recordManageAudit(c, "channel.update", map[string]interface{}{ + updateAudit := map[string]any{ "id": channel.Id, "name": channel.Name, "changed_fields": changedFields, - }) + } + if baseURLFromPluginDefault { + updateAudit["base_url_source"] = "plugin_default" + } + recordManageAudit(c, "channel.update", updateAudit) channel.Key = "" clearChannelInfo(&channel.Channel) c.JSON(http.StatusOK, gin.H{ @@ -1173,7 +1171,7 @@ func UpdateChannelStatus(c *gin.Context) { if changed { model.InitChannelCache() } - recordManageAudit(c, "channel.status_update", map[string]interface{}{ + recordManageAudit(c, "channel.status_update", map[string]any{ "id": id, "status": req.Status, "changed": changed, @@ -1200,7 +1198,7 @@ func BatchUpdateChannelStatus(c *gin.Context) { if changedCount > 0 { model.InitChannelCache() } - recordManageAudit(c, "channel.status_update_batch", map[string]interface{}{ + recordManageAudit(c, "channel.status_update_batch", map[string]any{ "count": changedCount, "total": len(req.Ids), "status": req.Status, @@ -1378,7 +1376,7 @@ func BatchSetChannelTag(c *gin.Context) { return } model.InitChannelCache() - recordManageAudit(c, "channel.tag_batch_set", map[string]interface{}{ + recordManageAudit(c, "channel.tag_batch_set", map[string]any{ "count": len(channelBatch.Ids), }) c.JSON(http.StatusOK, gin.H{ @@ -1489,7 +1487,7 @@ func CopyChannel(c *gin.Context) { return } model.InitChannelCache() - recordManageAudit(c, "channel.copy", map[string]interface{}{ + recordManageAudit(c, "channel.copy", map[string]any{ "sourceId": id, "id": clone.Id, "name": clone.Name, @@ -1564,7 +1562,7 @@ func ManageMultiKeys(c *gin.Context) { if request.Action == "get_key_status" { markAuditLogged(c) } else { - recordManageAudit(c, "channel.multi_key_manage", map[string]interface{}{ + recordManageAudit(c, "channel.multi_key_manage", map[string]any{ "action": request.Action, "id": channel.Id, }) @@ -1662,10 +1660,7 @@ func ManageMultiKeys(c *gin.Context) { // Calculate range for current page start := (page - 1) * pageSize - end := start + pageSize - if end > filteredTotal { - end = filteredTotal - } + end := min(start+pageSize, filteredTotal) // Get the page data var pageKeyStatusList []KeyStatus diff --git a/controller/channel_authz_test.go b/controller/channel_authz_test.go index 0a57eac50dd7..66e47b04463c 100644 --- a/controller/channel_authz_test.go +++ b/controller/channel_authz_test.go @@ -197,7 +197,7 @@ func TestChannelFieldsAreClassified(t *testing.T) { return names } - for _, name := range collect(reflect.TypeOf(PatchChannel{})) { + for _, name := range collect(reflect.TypeFor[PatchChannel]()) { assert.Truef(t, classified(name), "channel field %q is not classified; add it to channelSensitiveFields, channelNonSensitiveFields, channelOperationalFields, or channelReadOnlyFields in channel_authz.go", name) } diff --git a/controller/channel_task_plugin_bind_test.go b/controller/channel_task_plugin_bind_test.go index b8f326661594..6725399a1a59 100644 --- a/controller/channel_task_plugin_bind_test.go +++ b/controller/channel_task_plugin_bind_test.go @@ -31,7 +31,7 @@ func setupTaskPluginBindChannelTest(t *testing.T) { sqlDB, err := database.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(1) - require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.Log{}, &model.User{})) + require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.Log{}, &model.AuditLog{}, &model.User{})) model.DB = database model.LOG_DB = database require.NoError(t, authz.Init(database)) @@ -129,3 +129,38 @@ export function parseTaskResult() { return {}; } assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission") assert.Contains(t, recorder.Body.String(), `"success":false`) } + +func TestAddChannelTaskPluginPersistsPluginDefaultBaseURLAndAuditsSource(t *testing.T) { + setupTaskPluginBindChannelTest(t) + for key, baseURLField := range map[string]string{"bind-default-url": `baseUrl: "http://10.0.0.5:8000/",`, "bind-no-default": ""} { + source := fmt.Sprintf(` +export const meta = {apiVersion: 1, key: %q, name: "Bind", version: "1.0.0", author: {name: "Test"}, %s models: ["doc"], fetchMode: "per_task"}; +export function buildSubmitRequest() { return {}; } +export function parseSubmitResponse() { return {}; } +export function buildQueryRequest() { return {}; } +export function parseTaskResult() { return {}; } +`, key, baseURLField) + _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{}) + require.NoError(t, err) + t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) }) + } + body := func(pluginKey string) string { + return fmt.Sprintf(`{"mode":"single","channel":{"type":61,"name":"%s","key":"sk","models":"doc","group":"default","setting":"{\"task_plugin_key\":\"%s\"}"}}`, pluginKey, pluginKey) + } + + noDefault := postAddChannel(t, 1, common.RoleRootUser, body("bind-no-default")) + assert.Contains(t, noDefault.Body.String(), "base URL is required for task plugin channels") + + filled := postAddChannel(t, 1, common.RoleRootUser, body("bind-default-url")) + require.Contains(t, filled.Body.String(), `"success":true`) + var created model.Channel + require.NoError(t, model.DB.Where("name = ?", "bind-default-url").First(&created).Error) + require.NotNil(t, created.BaseURL) + assert.Equal(t, "http://10.0.0.5:8000", *created.BaseURL, "the normalized plugin default is stored on the channel row") + + var audits []model.AuditLog + require.NoError(t, model.LOG_DB.Where("action = ?", "channel.create").Find(&audits).Error) + encoded, err := common.Marshal(audits) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"base_url_source":"plugin_default"`) +} diff --git a/controller/channel_task_plugin_validation_test.go b/controller/channel_task_plugin_validation_test.go index 6dfe065040f5..88b0f2113b37 100644 --- a/controller/channel_task_plugin_validation_test.go +++ b/controller/channel_task_plugin_validation_test.go @@ -7,6 +7,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/jsplugin" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,3 +40,30 @@ export function parseTaskResult() { return {}; } channel.BaseURL = nil require.ErrorContains(t, validateChannel(channel, false), "base URL is required") } + +func TestValidateTaskPluginChannelFillsPluginDefaultBaseURL(t *testing.T) { + source := ` +export const meta = {apiVersion: 1, key: "channel-default-url", name: "Default URL", version: "1.0.0", author: {name: "Test"}, baseUrl: "http://127.0.0.1:8000/", models: ["doc"], fetchMode: "per_task"}; +export function buildSubmitRequest() { return {}; } +export function parseSubmitResponse() { return {}; } +export function buildQueryRequest() { return {}; } +export function parseTaskResult() { return {}; } +` + _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{}) + require.NoError(t, err) + t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("channel-default-url") }) + bound := `{"task_plugin_key":"channel-default-url"}` + + empty := " " + for _, baseURL := range []*string{nil, &empty} { + channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Key: "sk", Setting: &bound, BaseURL: baseURL} + require.NoError(t, validateChannel(channel, true)) + require.NotNil(t, channel.BaseURL) + assert.Equal(t, "http://127.0.0.1:8000", *channel.BaseURL, "normalized plugin default is persisted onto the channel") + } + + explicit := "https://override.example.com" + channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Setting: &bound, BaseURL: &explicit} + require.NoError(t, validateChannel(channel, false)) + assert.Equal(t, explicit, *channel.BaseURL, "an administrator value is never replaced by the plugin default") +} diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index 5f19aeb86610..66a394aada97 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -166,7 +166,7 @@ func TestCopyChannelRejectsInvalidLegacyProxySettings(t *testing.T) { func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) { db := setupModelListControllerTestDB(t) - require.NoError(t, db.AutoMigrate(&model.Log{})) + require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{})) service.ResetProxyClientCache() t.Cleanup(service.ResetProxyClientCache) @@ -189,7 +189,7 @@ func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) { func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) { db := setupModelListControllerTestDB(t) - require.NoError(t, db.AutoMigrate(&model.Log{})) + require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{})) channel := &model.Channel{Name: "existing", Key: "test-key"} require.NoError(t, db.Create(channel).Error) @@ -210,14 +210,16 @@ func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) { assert.True(t, response.Success) assert.Equal(t, int64(1), response.Data) - var auditLog model.Log + var auditLog model.AuditLog require.NoError(t, db.Order("id desc").First(&auditLog).Error) var auditData struct { Operation struct { Params map[string]any `json:"params"` } `json:"op"` } - require.NoError(t, common.UnmarshalJsonStr(auditLog.Other, &auditData)) + encodedAudit, err := common.Marshal(auditLog.Other) + require.NoError(t, err) + require.NoError(t, common.Unmarshal(encodedAudit, &auditData)) assert.Equal(t, float64(1), auditData.Operation.Params["count"]) } diff --git a/controller/channel_test_request_test.go b/controller/channel_test_request_test.go new file mode 100644 index 000000000000..fc6058626556 --- /dev/null +++ b/controller/channel_test_request_test.go @@ -0,0 +1,236 @@ +package controller + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/ali" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/gin-gonic/gin" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func convertChatCompatibilityRequest(t *testing.T, request *dto.GeneralOpenAIRequest, channelType int, mapping map[string]string) []byte { + t.Helper() + oldMode := gin.Mode() + gin.SetMode(gin.TestMode) + t.Cleanup(func() { gin.SetMode(oldMode) }) + settings := model_setting.GetGlobalSettings() + oldPassThrough, oldBlacklist := settings.PassThroughRequestEnabled, settings.ThinkingModelBlacklist + oldEffortTailModels := settings.EffortTailModelIDs + settings.PassThroughRequestEnabled = false + settings.ThinkingModelBlacklist = nil + settings.EffortTailModelIDs = []string{"gpt-5.1-codex-max"} + t.Cleanup(func() { + settings.PassThroughRequestEnabled = oldPassThrough + settings.ThinkingModelBlacklist = oldBlacklist + settings.EffortTailModelIDs = oldEffortTailModels + }) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) + if mapping != nil { + encoded, err := common.Marshal(mapping) + require.NoError(t, err) + c.Set("model_mapping", string(encoded)) + } + info := &relaycommon.RelayInfo{ + OriginModelName: request.Model, + Request: request, + RelayFormat: types.RelayFormatOpenAI, + IsStream: request.IsStream(nil), + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: channelType, + UpstreamModelName: request.Model, + SupportStreamOptions: true, + }, + } + require.NoError(t, helper.ModelMappedHelper(c, info, request)) + require.NoError(t, helper.ApplyReasoningModelSuffix(c, info, request)) + var converted any + var err error + if channelType == constant.ChannelTypeAli { + converted, err = (&ali.Adaptor{}).ConvertOpenAIRequest(c, info, request) + } else { + converted, err = (&openai.Adaptor{}).ConvertOpenAIRequest(c, info, request) + } + require.NoError(t, err) + encoded, err := common.Marshal(converted) + require.NoError(t, err) + return encoded +} + +func TestChannelTestOpenAIChatCompatibility(t *testing.T) { + for _, tt := range []struct { + name string + model string + upstream string + endpoint string + channelType int + stream bool + wantLimit string + }{ + {name: "GPT6 automatic", model: "gpt-6-astra", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"}, + {name: "GPT6 explicit Azure stream", model: "gpt-6-astra", upstream: "gpt-6-astra", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeAzure, stream: true, wantLimit: "max_completion_tokens"}, + {name: "alias maps to GPT6", model: "customer-model", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"}, + {name: "GPT5 alias maps to Qwen", model: "gpt-5.6-luna", upstream: "qwen-turbo", channelType: constant.ChannelTypeAli, wantLimit: "max_tokens"}, + {name: "GPT5 stream", model: "gpt-5.6-luna", upstream: "gpt-5.6-luna", channelType: constant.ChannelTypeOpenAI, stream: true, wantLimit: "max_completion_tokens"}, + {name: "GPT4 explicit", model: "gpt-4.1", upstream: "gpt-4.1", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeOpenAI, wantLimit: "max_tokens"}, + {name: "o series", model: "o3-mini", upstream: "o3-mini", channelType: constant.ChannelTypeAzure, wantLimit: "max_completion_tokens"}, + } { + t.Run(tt.name, func(t *testing.T) { + request, ok := buildTestRequest(tt.model, tt.endpoint, &model.Channel{}, tt.stream).(*dto.GeneralOpenAIRequest) + require.True(t, ok) + encoded := convertChatCompatibilityRequest(t, request, tt.channelType, map[string]string{tt.model: tt.upstream}) + want := map[string]any{ + "model": tt.upstream, + "messages": []dto.Message{{Role: "user", Content: "hi"}}, + "stream": tt.stream, + tt.wantLimit: 16, + } + if tt.stream { + want["stream_options"] = map[string]any{"include_usage": true} + } + wantJSON, err := common.Marshal(want) + require.NoError(t, err) + assert.JSONEq(t, string(wantJSON), string(encoded)) + }) + } +} + +func TestOpenAIChatSamplingCompatibility(t *testing.T) { + const sampling = `{"temperature":0.2,"top_p":0.8,"logprobs":true,"top_logprobs":5}` + for _, tt := range []struct { + name string + model string + effort string + reasoning string + mapping map[string]string + zeroValues bool + wantModel string + wantEffort string + wantRole string + wantParams string + }{ + {name: "GPT5.1 explicit none", model: "gpt-5.1", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling}, + {name: "GPT5.2 default none", model: "gpt-5.2", wantRole: "developer", wantParams: sampling}, + {name: "GPT5.2 dated snapshot", model: "gpt-5.2-2025-12-11", wantRole: "developer", wantParams: sampling}, + {name: "GPT5.4 reasoning", model: "gpt-5.4", effort: "high", wantEffort: "high", wantRole: "developer", wantParams: `{}`}, + {name: "GPT5.4 snapshot none", model: "gpt-5.4-2026-03-05", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling}, + {name: "explicit zero values", model: "gpt-5.4", effort: "none", zeroValues: true, wantEffort: "none", wantRole: "developer", wantParams: `{"temperature":0,"top_p":0,"logprobs":false}`}, + {name: "GPT5 original", model: "gpt-5", wantRole: "developer", wantParams: `{}`}, + {name: "GPT5.6 existing policy", model: "gpt-5.6-luna", wantRole: "developer", wantParams: `{}`}, + {name: "pro variant", model: "gpt-5.2-pro-2025-12-11", wantRole: "developer", wantParams: `{}`}, + {name: "chat variant", model: "gpt-5.2-chat-latest", wantRole: "developer", wantParams: `{}`}, + {name: "codex model name keeps max", model: "gpt-5.1-codex-max", wantRole: "developer", wantParams: `{}`}, + {name: "GPT6", model: "gpt-6-astra", wantRole: "developer", wantParams: `{}`}, + {name: "GPT6 snapshot", model: "gpt-6-astra-2026-09-03", wantRole: "developer", wantParams: `{}`}, + {name: "GPT6 effort suffix", model: "gpt-6-astra-high", wantModel: "gpt-6-astra", wantEffort: "high", wantRole: "developer", wantParams: `{}`}, + {name: "none effort suffix", model: "gpt-5.2-none", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling}, + {name: "modifier overrides explicit effort", model: "gpt-5.2@thinking:off", effort: "high", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling}, + {name: "mapped modifier wins", model: "customer-model@thinking:off", mapping: map[string]string{"customer-model": "gpt-5.2@effort:high"}, wantModel: "gpt-5.2", wantEffort: "high", wantRole: "developer", wantParams: `{}`}, + {name: "nested reasoning disabled", model: "gpt-5.2", reasoning: `{"enabled":false}`, wantEffort: "none", wantRole: "developer", wantParams: sampling}, + {name: "o1 mini role exception", model: "o1-mini", wantRole: "system", wantParams: `{"top_p":0.8,"logprobs":true,"top_logprobs":5}`}, + {name: "GPT4 unchanged", model: "gpt-4.1", wantRole: "system", wantParams: sampling}, + {name: "future model unchanged", model: "gpt-7", wantRole: "system", wantParams: sampling}, + } { + t.Run(tt.name, func(t *testing.T) { + request := &dto.GeneralOpenAIRequest{ + Model: tt.model, + Messages: []dto.Message{ + {Role: "system", Content: "first instruction"}, + {Role: "system", Content: "second instruction"}, + {Role: "user", Content: "hi"}, + }, + ReasoningEffort: tt.effort, + } + require.NoError(t, common.UnmarshalJsonStr(sampling, request)) + if tt.reasoning != "" { + request.Reasoning = []byte(tt.reasoning) + } + if tt.zeroValues { + request.Temperature = lo.ToPtr(0.0) + request.TopP = lo.ToPtr(0.0) + request.LogProbs = lo.ToPtr(false) + request.TopLogProbs = nil + } + encoded := convertChatCompatibilityRequest(t, request, constant.ChannelTypeOpenAI, tt.mapping) + var want map[string]any + require.NoError(t, common.UnmarshalJsonStr(tt.wantParams, &want)) + want["model"] = tt.model + if tt.wantModel != "" { + want["model"] = tt.wantModel + } + want["messages"] = []dto.Message{ + {Role: tt.wantRole, Content: "first instruction"}, + {Role: "system", Content: "second instruction"}, + {Role: "user", Content: "hi"}, + } + if tt.wantEffort != "" { + want["reasoning_effort"] = tt.wantEffort + } + wantJSON, err := common.Marshal(want) + require.NoError(t, err) + assert.JSONEq(t, string(wantJSON), string(encoded)) + }) + } +} + +func TestOpenAIChatTokenLimitCompatibility(t *testing.T) { + for _, modelName := range []string{"gpt-5", "o3-mini", "gpt-6-astra"} { + for _, tt := range []struct { + name string + input string + want string + }{ + {name: "omitted", input: `{}`, want: `{}`}, + {name: "legacy only", input: `{"max_tokens":100}`, want: `{"max_completion_tokens":100}`}, + {name: "completion only", input: `{"max_completion_tokens":50}`, want: `{"max_completion_tokens":50}`}, + {name: "both positive stay present", input: `{"max_tokens":100,"max_completion_tokens":50}`, want: `{"max_tokens":100,"max_completion_tokens":50}`}, + {name: "zero completion falls back", input: `{"max_tokens":100,"max_completion_tokens":0}`, want: `{"max_completion_tokens":100}`}, + {name: "legacy zero stays present", input: `{"max_tokens":0}`, want: `{"max_tokens":0}`}, + {name: "completion zero stays present", input: `{"max_completion_tokens":0}`, want: `{"max_completion_tokens":0}`}, + {name: "both zero stay present", input: `{"max_tokens":0,"max_completion_tokens":0}`, want: `{"max_tokens":0,"max_completion_tokens":0}`}, + } { + t.Run(modelName+"/"+tt.name, func(t *testing.T) { + request := &dto.GeneralOpenAIRequest{Model: modelName, Messages: []dto.Message{{Role: "user", Content: "hi"}}} + require.NoError(t, common.UnmarshalJsonStr(tt.input, request)) + encoded := convertChatCompatibilityRequest(t, request, constant.ChannelTypeOpenAI, nil) + want := dto.GeneralOpenAIRequest{Model: modelName, Messages: []dto.Message{{Role: "user", Content: "hi"}}} + require.NoError(t, common.UnmarshalJsonStr(tt.want, &want)) + wantJSON, err := common.Marshal(want) + require.NoError(t, err) + assert.JSONEq(t, string(wantJSON), string(encoded)) + }) + } + } +} + +func TestDirectOpenAIResponsesKeepsExistingParameters(t *testing.T) { + const body = `{"model":"gpt-6-astra","input":"hi","max_output_tokens":100,"temperature":0.2,"top_p":0.8,"top_logprobs":5,"include":["message.output_text.logprobs"],"reasoning":{"effort":"high"}}` + var request dto.OpenAIResponsesRequest + require.NoError(t, common.UnmarshalJsonStr(body, &request)) + info := &relaycommon.RelayInfo{ + OriginModelName: "gpt-6-astra", + RelayFormat: types.RelayFormatOpenAIResponses, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeOpenAI, + UpstreamModelName: "gpt-6-astra", + }, + } + converted, err := (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, request) + require.NoError(t, err) + encoded, err := common.Marshal(converted) + require.NoError(t, err) + assert.JSONEq(t, body, string(encoded)) +} diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 6817a0086ac6..ef1b6debc70c 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -498,7 +498,7 @@ func fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) func updateChannelUpstreamModelSettings(channel *model.Channel, settings dto.ChannelOtherSettings, updateModels bool) error { channel.SetOtherSettings(settings) - updates := map[string]interface{}{ + updates := map[string]any{ "settings": channel.OtherSettings, } if updateModels { @@ -889,7 +889,7 @@ func ApplyChannelUpstreamModelUpdates(c *gin.Context) { refreshChannelRuntimeCache() } - recordManageAudit(c, "channel.upstream_apply", map[string]interface{}{ + recordManageAudit(c, "channel.upstream_apply", map[string]any{ "id": channel.Id, }) c.JSON(http.StatusOK, gin.H{ @@ -1087,7 +1087,7 @@ func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) { refreshChannelRuntimeCache() } - recordManageAudit(c, "channel.upstream_apply_all", map[string]interface{}{ + recordManageAudit(c, "channel.upstream_apply_all", map[string]any{ "count": len(results), }) c.JSON(http.StatusOK, gin.H{ @@ -1128,7 +1128,7 @@ func DetectAllChannelUpstreamModelUpdates(c *gin.Context) { return } - recordManageAudit(c, "channel.upstream_detect_all", map[string]interface{}{ + recordManageAudit(c, "channel.upstream_detect_all", map[string]any{ "task_id": task.TaskID, }) c.JSON(http.StatusOK, gin.H{ diff --git a/controller/channel_upstream_update_test.go b/controller/channel_upstream_update_test.go index 5cb4fac4b0bf..9948d0081725 100644 --- a/controller/channel_upstream_update_test.go +++ b/controller/channel_upstream_update_test.go @@ -534,7 +534,7 @@ func TestCollectPendingUpstreamModelChangesFromModels_WithIgnoredRegexPatterns(t func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *testing.T) { channelSummaries := make([]upstreamModelUpdateChannelSummary, 0, 12) - for i := 0; i < 12; i++ { + for i := range 12 { channelSummaries = append(channelSummaries, upstreamModelUpdateChannelSummary{ ChannelName: "channel-" + string(rune('A'+i)), AddCount: i + 1, diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index 8172e29718f3..ff448a423b43 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -10,8 +10,10 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" ) @@ -521,27 +523,47 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) { // UnbindCustomOAuth unbinds a custom OAuth provider from the current user func UnbindCustomOAuth(c *gin.Context) { - userId := c.GetInt("id") - if userId == 0 { - common.ApiErrorMsg(c, "未登录") + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + writeSecurityOperationError(c, service.ErrAuthTokenInvalid) return } providerIdStr := c.Param("provider_id") providerId, err := strconv.Atoi(providerIdStr) - if err != nil { + if err != nil || providerId <= 0 { common.ApiErrorMsg(c, "无效的提供商 ID") return } - if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil { - common.ApiError(c, err) + succeeded, notificationFailed := false, false + defer func() { + recordUserSecurityAudit(c, identity.UserID, "user.binding_unbind", map[string]any{"provider_id": providerId, "success": succeeded, "notification_failed": notificationFailed}) + }() + context, err := common.Marshal(service.AccountUnbindingContext{ProviderID: providerId}) + if err != nil { + writeSecurityOperationError(c, err) + return + } + if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountUnbind, Context: context}) == nil { + return + } + if err := service.UnbindAccountOAuth(identity, providerId); err != nil { + writeSecurityOperationError(c, err) + return + } + succeeded = true + user, err := model.GetUserById(identity.UserID, false) + if err != nil { + writeSecurityOperationError(c, err) return } + notificationFailed = service.NotifyAccountSecurityChange(user.Email, "Login account unlinked") != nil c.JSON(http.StatusOK, gin.H{ "success": true, "message": "解绑成功", + "data": gin.H{"notification_warning": notificationFailed}, }) } diff --git a/controller/deployment.go b/controller/deployment.go index a2ffedc6675f..6dc51149eacd 100644 --- a/controller/deployment.go +++ b/controller/deployment.go @@ -135,7 +135,7 @@ func requireContainerID(c *gin.Context) (string, bool) { return containerID, true } -func mapIoNetDeployment(d ionet.Deployment) map[string]interface{} { +func mapIoNetDeployment(d ionet.Deployment) map[string]any { var created int64 if d.CreatedAt.IsZero() { created = time.Now().Unix() @@ -156,7 +156,7 @@ func mapIoNetDeployment(d ionet.Deployment) map[string]interface{} { hardwareInfo := fmt.Sprintf("%s %s x%d", d.BrandName, d.HardwareName, d.HardwareQuantity) - return map[string]interface{}{ + return map[string]any{ "id": d.ID, "deployment_name": d.Name, "container_name": d.Name, @@ -176,7 +176,7 @@ func mapIoNetDeployment(d ionet.Deployment) map[string]interface{} { "model_name": "", "model_version": "", "instance_count": d.HardwareQuantity, - "resource_config": map[string]interface{}{ + "resource_config": map[string]any{ "cpu": "", "memory": "", "gpu": strconv.Itoa(d.HardwareQuantity), @@ -225,7 +225,7 @@ func GetAllDeployments(c *gin.Context) { return } - items := make([]map[string]interface{}, 0, len(dl.Deployments)) + items := make([]map[string]any, 0, len(dl.Deployments)) for _, d := range dl.Deployments { items = append(items, mapIoNetDeployment(d)) } @@ -274,7 +274,7 @@ func SearchDeployments(c *gin.Context) { } } - items := make([]map[string]interface{}, 0, len(filtered)) + items := make([]map[string]any, 0, len(filtered)) for _, d := range filtered { items = append(items, mapIoNetDeployment(d)) } @@ -310,7 +310,7 @@ func GetDeployment(c *gin.Context) { return } - data := map[string]interface{}{ + data := map[string]any{ "id": details.ID, "deployment_name": details.ID, "model_name": "", @@ -318,7 +318,7 @@ func GetDeployment(c *gin.Context) { "status": strings.ToLower(details.Status), "instance_count": details.TotalContainers, "hardware_id": details.HardwareID, - "resource_config": map[string]interface{}{ + "resource_config": map[string]any{ "cpu": "", "memory": "", "gpu": strconv.Itoa(details.TotalGPUs), @@ -668,10 +668,7 @@ func GetDeploymentLogs(c *gin.Context) { var limit int = 100 if limitStr != "" { if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - limit = parsedLimit - if limit > 1000 { - limit = 1000 - } + limit = min(parsedLimit, 1000) } } @@ -720,19 +717,19 @@ func ListDeploymentContainers(c *gin.Context) { return } - items := make([]map[string]interface{}, 0) + items := make([]map[string]any, 0) if containers != nil { - items = make([]map[string]interface{}, 0, len(containers.Workers)) + items = make([]map[string]any, 0, len(containers.Workers)) for _, ctr := range containers.Workers { - events := make([]map[string]interface{}, 0, len(ctr.ContainerEvents)) + events := make([]map[string]any, 0, len(ctr.ContainerEvents)) for _, event := range ctr.ContainerEvents { - events = append(events, map[string]interface{}{ + events = append(events, map[string]any{ "time": event.Time.Unix(), "message": event.Message, }) } - items = append(items, map[string]interface{}{ + items = append(items, map[string]any{ "container_id": ctr.ContainerID, "device_id": ctr.DeviceID, "status": strings.ToLower(strings.TrimSpace(ctr.Status)), @@ -784,9 +781,9 @@ func GetContainerDetails(c *gin.Context) { return } - events := make([]map[string]interface{}, 0, len(details.ContainerEvents)) + events := make([]map[string]any, 0, len(details.ContainerEvents)) for _, event := range details.ContainerEvents { - events = append(events, map[string]interface{}{ + events = append(events, map[string]any{ "time": event.Time.Unix(), "message": event.Message, }) diff --git a/controller/email_binding.go b/controller/email_binding.go new file mode 100644 index 000000000000..61869c7d4f05 --- /dev/null +++ b/controller/email_binding.go @@ -0,0 +1,112 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +type emailBindRequest struct { + Email string `json:"email"` + FlowToken string `json:"flow_token"` + NewCode string `json:"new_code"` + OldCode string `json:"old_code"` +} + +func EmailBindStart(c *gin.Context) { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + writeSecurityOperationError(c, service.ErrAuthTokenInvalid) + return + } + succeeded, notificationFailed := false, false + defer func() { + recordUserSecurityAudit(c, identity.UserID, "user.binding_start", map[string]any{"provider": "email", "success": succeeded, "notification_failed": notificationFailed}) + }() + var request emailBindRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + writeSecurityOperationError(c, service.ErrVerificationContextInvalid) + return + } + email, err := service.ValidateAccountEmail(request.Email) + if err != nil { + writeSecurityOperationError(c, err) + return + } + context, err := common.Marshal(service.AccountBindingContext{Provider: "email", Email: email}) + if err != nil { + writeSecurityOperationError(c, err) + return + } + authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}) + if authorization == nil { + return + } + data, err := service.StartEmailBinding(identity, authorization, email) + if err != nil { + writeSecurityOperationError(c, err) + return + } + succeeded, notificationFailed = true, data.NotificationWarning + common.ApiSuccess(c, data) +} + +func EmailBindResend(c *gin.Context) { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + writeSecurityOperationError(c, service.ErrAuthTokenInvalid) + return + } + succeeded := false + defer func() { + recordUserSecurityAudit(c, identity.UserID, "user.email_binding_resend", map[string]any{"success": succeeded}) + }() + var request emailBindRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.FlowToken == "" { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) + return + } + data, err := service.ResendAccountEmailBinding(identity, request.FlowToken) + if err != nil { + writeSecurityOperationError(c, err) + return + } + succeeded = true + common.ApiSuccess(c, data) +} + +func EmailBind(c *gin.Context) { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + writeSecurityOperationError(c, service.ErrAuthTokenInvalid) + return + } + succeeded, notificationFailed := false, false + defer func() { + recordUserSecurityAudit(c, identity.UserID, "user.binding_bind", map[string]any{"provider": "email", "success": succeeded, "notification_failed": notificationFailed}) + }() + var request emailBindRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.FlowToken == "" { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) + return + } + state, err := service.FinishEmailBinding(identity, request.FlowToken, request.NewCode, request.OldCode) + if err != nil { + writeSecurityOperationError(c, err) + return + } + succeeded = true + notificationFailed = service.NotifyAccountSecurityChange(state.CurrentEmail, "Email address changed") != nil + if err := service.NotifyAccountSecurityChange(state.Email, "Email address confirmed"); err != nil { + notificationFailed = true + } + if err := model.PublishUserAuthCache(identity.UserID); err != nil { + writeSecurityOperationError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"notification_warning": notificationFailed}}) +} diff --git a/controller/group.go b/controller/group.go index 6ba339a3f9bd..6d1abec42c8e 100644 --- a/controller/group.go +++ b/controller/group.go @@ -24,7 +24,7 @@ func GetGroups(c *gin.Context) { } func GetUserGroups(c *gin.Context) { - usableGroups := make(map[string]map[string]interface{}) + usableGroups := make(map[string]map[string]any) userGroup := "" userId := c.GetInt("id") userGroup, _ = model.GetUserGroup(userId, false) @@ -32,14 +32,14 @@ func GetUserGroups(c *gin.Context) { for groupName, _ := range ratio_setting.GetGroupRatioCopy() { // UserUsableGroups contains the groups that the user can use if desc, ok := userUsableGroups[groupName]; ok { - usableGroups[groupName] = map[string]interface{}{ + usableGroups[groupName] = map[string]any{ "ratio": service.GetUserGroupRatio(userGroup, groupName), "desc": desc, } } } if _, ok := userUsableGroups["auto"]; ok { - usableGroups["auto"] = map[string]interface{}{ + usableGroups["auto"] = map[string]any{ "ratio": "自动", "desc": setting.GetUsableGroupDescription("auto"), } diff --git a/controller/login_verification.go b/controller/login_verification.go new file mode 100644 index 000000000000..6d89b30d2f8d --- /dev/null +++ b/controller/login_verification.go @@ -0,0 +1,151 @@ +package controller + +import ( + "encoding/json" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + passkeysvc "github.com/QuantumNous/new-api/service/passkey" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + webauthnlib "github.com/go-webauthn/webauthn/webauthn" +) + +func VerifyLogin(c *gin.Context) { + var request struct { + FlowToken string `json:"flow_token"` + Method string `json:"method"` + Code string `json:"code"` + } + if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.Code == "" { + common.ApiErrorMsg(c, "参数错误") + return + } + if request.Method == "" { + request.Method = service.VerificationMethodTwoFA + } + if request.Method != service.VerificationMethodTwoFA { + writeSecurityOperationError(c, service.ErrProofMethod) + return + } + bundle, err := service.VerifyLoginCode(request.FlowToken, request.Code, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + writeSecurityOperationError(c, err) + return + } + completeVerifiedLoginResponse(c, bundle, service.VerificationMethodTwoFA) +} + +func LoginPasskeyBegin(c *gin.Context) { + var request struct { + FlowToken string `json:"flow_token"` + } + if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" { + common.ApiErrorMsg(c, "参数错误") + return + } + verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey) + if err != nil { + writeSecurityOperationError(c, err) + return + } + credential, err := model.GetPasskeyByUserID(verification.State.UserID) + if err != nil { + writeSecurityOperationError(c, err) + return + } + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + writeSecurityOperationError(c, err) + return + } + user := &model.User{Id: verification.State.UserID} + options, sessionData, err := wa.BeginLogin(passkeysvc.NewWebAuthnUser(user, credential), webauthnlib.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + writeSecurityOperationError(c, err) + return + } + token, expiresAt, err := passkeysvc.CreateSessionDataFlow(model.AuthFlowPurposeLoginPasskey, passkeysvc.FlowSecurity{ + AuthSessionIdentity: model.AuthSessionIdentity{UserID: user.Id, UserAuthVersion: verification.State.AuthVersion}, + LoginFlowID: verification.Flow.Id, LoginExpiresAt: verification.Flow.ExpiresAt.Unix(), + }, sessionData) + if err != nil { + writeSecurityOperationError(c, err) + return + } + common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options}) +} + +func LoginPasskeyFinish(c *gin.Context) { + var request struct { + FlowToken string `json:"flow_token"` + PasskeyFlowToken string `json:"passkey_flow_token"` + Credential json.RawMessage `json:"credential"` + } + if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.PasskeyFlowToken == "" || len(request.Credential) == 0 { + common.ApiErrorMsg(c, "参数错误") + return + } + verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey) + if err != nil { + writeSecurityOperationError(c, err) + return + } + parsed, err := protocol.ParseCredentialRequestResponseBytes(request.Credential) + if err != nil { + writeSecurityOperationError(c, err) + return + } + identity := model.AuthSessionIdentity{UserID: verification.State.UserID, UserAuthVersion: verification.State.AuthVersion} + sessionData, security, err := passkeysvc.PopSessionDataFlow(request.PasskeyFlowToken, model.AuthFlowPurposeLoginPasskey, identity) + if err != nil { + writeSecurityOperationError(c, err) + return + } + if security.LoginFlowID != verification.Flow.Id || sessionData.UserVerification != protocol.VerificationRequired { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) + return + } + credential, err := model.GetPasskeyByUserID(identity.UserID) + if err != nil { + writeSecurityOperationError(c, err) + return + } + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + writeSecurityOperationError(c, err) + return + } + validated, err := wa.ValidateLogin(passkeysvc.NewWebAuthnUser(&model.User{Id: identity.UserID}, credential), *sessionData, parsed) + if err != nil { + writeSecurityOperationError(c, err) + return + } + if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now()); err != nil { + writeSecurityOperationError(c, err) + return + } + bundle, err := service.CompleteLoginVerification(request.FlowToken, verification, service.VerificationMethodPasskey, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + writeSecurityOperationError(c, err) + return + } + completeVerifiedLoginResponse(c, bundle, service.VerificationMethodPasskey) +} + +func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, method string) { + identity, err := service.ParseAccessToken(bundle.AccessToken) + if err != nil { + writeAuthSessionError(c, err) + return + } + user, err := model.GetSelfUserById(identity.UserID) + if err != nil { + writeAuthSessionError(c, err) + return + } + c.Set("login_verification_method", method) + writeLoginResponse(c, user, bundle) +} diff --git a/controller/misc.go b/controller/misc.go index 9f7480ef958e..18105a583e5a 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -14,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/console_setting" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -62,6 +62,7 @@ func GetStatus(c *gin.Context) { "linuxdo_client_id": common.LinuxDOClientId, "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_oauth_configured": oauth.TelegramConfigurationError() == nil, "telegram_bot_name": common.TelegramBotName, "theme": "default", "system_name": common.SystemName, @@ -215,47 +216,11 @@ func GetHomePageContent(c *gin.Context) { } func SendEmailVerification(c *gin.Context) { - email := model.NormalizeEmail(c.Query("email")) - if err := common.Validate.Var(email, "required,email"); err != nil { - common.ApiErrorI18n(c, i18n.MsgInvalidParams) - return - } - parts := strings.Split(email, "@") - if len(parts) != 2 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的邮箱地址", - }) + email, err := service.ValidateAccountEmail(c.Query("email")) + if err != nil { + writeSecurityOperationError(c, err) return } - localPart := parts[0] - domainPart := parts[1] - if common.EmailDomainRestrictionEnabled { - allowed := false - for _, domain := range common.EmailDomainWhitelist { - if domainPart == domain { - allowed = true - break - } - } - if !allowed { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "The administrator has enabled the email domain name whitelist, and your email address is not allowed due to special symbols or it's not in the whitelist.", - }) - return - } - } - if common.EmailAliasRestrictionEnabled { - containsSpecialSymbols := strings.Contains(localPart, "+") || strings.Contains(localPart, ".") - if containsSpecialSymbols { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。", - }) - return - } - } if model.IsEmailAlreadyTaken(email) { common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) @@ -267,7 +232,7 @@ func SendEmailVerification(c *gin.Context) { content := fmt.Sprintf("
您好,你正在进行%s邮箱验证。
"+ "您的验证码为: %s
"+ "验证码 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, code, common.VerificationValidMinutes) - err := common.SendEmail(subject, email, content) + err = common.SendEmail(subject, email, content) if err != nil { common.ApiError(c, err) return diff --git a/controller/model.go b/controller/model.go index 779739477fe1..6f81801faf32 100644 --- a/controller/model.go +++ b/controller/model.go @@ -34,7 +34,7 @@ var channelId2Models map[int][]string func init() { // https://platform.openai.com/docs/models/model-endpoint-compatibility - for i := 0; i < constant.APITypeDummy; i++ { + for i := range constant.APITypeDummy { if i == constant.APITypeAIProxyLibrary { continue } @@ -251,7 +251,7 @@ func ListModels(c *gin.Context, modelType int) { models := service.GetGroupsEnabledModels(ownerGroups) for _, modelName := range models { if modelLimitEnable { - matchingName := ratio_setting.FormatMatchingModelName(modelName) + matchingName := ratio_setting.RoutingMatchModelName(modelName) if !tokenModelLimit[modelName] && !tokenModelLimit[matchingName] { continue } diff --git a/controller/model_list_test.go b/controller/model_list_test.go index 812207b8fd44..a28ae2909c86 100644 --- a/controller/model_list_test.go +++ b/controller/model_list_test.go @@ -493,49 +493,9 @@ func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) { require.Empty(t, anthropicResponse.LastID) } -func TestCheckUpdatePasswordRequiresCurrentPassword(t *testing.T) { - db := setupModelListControllerTestDB(t) - hashedPassword, err := common.Password2Hash("CurrentPassword123") - require.NoError(t, err) - user := &model.User{ - Username: "password-user", - Password: hashedPassword, - Status: common.UserStatusEnabled, - } - require.NoError(t, db.Create(user).Error) - - updatePassword, err := checkUpdatePassword("", "", user.Id) - require.NoError(t, err) - assert.False(t, updatePassword) - - updatePassword, err = checkUpdatePassword("", "NewPassword123", user.Id) - require.Error(t, err) - assert.False(t, updatePassword) - assert.ErrorIs(t, err, errOriginalPasswordFail) - - updatePassword, err = checkUpdatePassword("CurrentPassword123", "NewPassword123", user.Id) - require.NoError(t, err) - assert.True(t, updatePassword) -} - -func TestCheckUpdatePasswordRejectsHistoricalEmptyPassword(t *testing.T) { - db := setupModelListControllerTestDB(t) - user := &model.User{ - Username: "legacy-passwordless-user", - Password: "", - Status: common.UserStatusEnabled, - } - require.NoError(t, db.Create(user).Error) - - updatePassword, err := checkUpdatePassword("", "NewPassword123", user.Id) - require.Error(t, err) - assert.False(t, updatePassword) - assert.ErrorIs(t, err, errUserPasswordUnset) -} - func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) { db := setupModelListControllerTestDB(t) - require.NoError(t, db.AutoMigrate(&model.Log{}, &model.UserSession{})) + require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}, &model.UserSession{}, &model.TwoFA{}, &model.PasskeyCredential{})) hashedPassword, err := common.Password2Hash("CurrentPassword123") require.NoError(t, err) @@ -551,11 +511,12 @@ func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) { router := gin.New() router.GET("/", func(c *gin.Context) { setupLogin(&model.User{ - Id: user.Id, - Username: user.Username, - Role: user.Role, - Status: user.Status, - Group: user.Group, + Id: user.Id, + AuthVersion: user.AuthVersion, + Username: user.Username, + Role: user.Role, + Status: user.Status, + Group: user.Group, }, c) }) diff --git a/controller/model_management_test.go b/controller/model_management_test.go new file mode 100644 index 000000000000..e97375914764 --- /dev/null +++ b/controller/model_management_test.go @@ -0,0 +1,1136 @@ +package controller + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/pkg/jsplugin" + "github.com/QuantumNous/new-api/setting/billing_setting" + "github.com/QuantumNous/new-api/setting/config" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func modelManagementDB(t *testing.T, kind, dsn string) *gorm.DB { + t.Helper() + database, isolatedDSN := newAuditTestDatabase(t, kind, dsn) + previousDB, previousLogDB := model.DB, model.LOG_DB + previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType() + previousMaster, previousSQLite := common.IsMasterNode, common.SQLitePath + previousRedis, previousMemory := common.RedisEnabled, common.MemoryCacheEnabled + previousOptions := common.OptionMap + previousConfig := config.GlobalConfig.ExportAllConfigs() + restoreRatios := []struct { + value string + restore func(string) error + }{ + {ratio_setting.ModelPrice2JSONString(), ratio_setting.UpdateModelPriceByJSONString}, + {ratio_setting.ModelRatio2JSONString(), ratio_setting.UpdateModelRatioByJSONString}, + {ratio_setting.CompletionRatio2JSONString(), ratio_setting.UpdateCompletionRatioByJSONString}, + {ratio_setting.CacheRatio2JSONString(), ratio_setting.UpdateCacheRatioByJSONString}, + {ratio_setting.CreateCacheRatio2JSONString(), ratio_setting.UpdateCreateCacheRatioByJSONString}, + {ratio_setting.ImageRatio2JSONString(), ratio_setting.UpdateImageRatioByJSONString}, + {ratio_setting.AudioRatio2JSONString(), ratio_setting.UpdateAudioRatioByJSONString}, + {ratio_setting.AudioCompletionRatio2JSONString(), ratio_setting.UpdateAudioCompletionRatioByJSONString}, + } + common.IsMasterNode = false + common.RedisEnabled, common.MemoryCacheEnabled = false, false + common.OptionMap = map[string]string{} + if kind == "sqlite" { + common.SQLitePath = isolatedDSN + isolatedDSN = "local" + } + t.Setenv("SQL_DSN", isolatedDSN) + t.Setenv("LOG_SQL_DSN", "") + require.NoError(t, model.InitDB()) + database = model.DB + model.LOG_DB = database + require.NoError(t, database.AutoMigrate(&model.Model{}, &model.Vendor{}, &model.Channel{}, &model.Ability{}, &model.Option{}, &model.User{}, &model.AuditLog{})) + for _, value := range restoreRatios { + require.NoError(t, value.restore("{}")) + } + config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": "{}", "billing_expr": "{}"}) + var version string + query := "SELECT version()" + if kind == "sqlite" { + query = "SELECT sqlite_version()" + } + require.NoError(t, database.Raw(query).Scan(&version).Error) + t.Logf("database version: %s", version) + t.Cleanup(func() { + for _, value := range restoreRatios { + require.NoError(t, value.restore(value.value)) + } + config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": previousConfig["billing_setting.billing_mode"], "billing_expr": previousConfig["billing_setting.billing_expr"]}) + common.OptionMap = previousOptions + common.IsMasterNode, common.SQLitePath = previousMaster, previousSQLite + common.RedisEnabled, common.MemoryCacheEnabled = previousRedis, previousMemory + common.SetDatabaseTypes(previousMain, previousLog) + connection, err := database.DB() + if err == nil { + require.NoError(t, connection.Close()) + } + model.DB, model.LOG_DB = previousDB, previousLogDB + }) + return database +} + +func modelManagementRequest(t *testing.T, handler gin.HandlerFunc, method, path string, body any, output any) *httptest.ResponseRecorder { + t.Helper() + encoded, err := common.Marshal(body) + require.NoError(t, err) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest(method, path, bytes.NewReader(encoded)) + context.Set("role", common.RoleRootUser) + handler(context) + if output != nil { + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), output), recorder.Body.String()) + } + return recorder +} + +func TestModelManagementDatabaseMatrix(t *testing.T) { + _, err := jsplugin.DefaultRegistry.Register(` +export const meta = {apiVersion: 1, key: "model-management-task", name: "Management task fixture", version: "1.0.0", author: {name: "Test"}, models: ["matrix-task"], fetchMode: "per_task", usageSchema: {seconds: {type: "number", unit: "second"}}}; +export function buildSubmitRequest() { return {}; } +export function parseSubmitResponse() { return {}; } +export function buildQueryRequest() { return {}; } +export function parseTaskResult() { return {}; } +`, jsplugin.Options{}) + require.NoError(t, err) + t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("model-management-task") }) + for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} { + t.Run(dialect.kind, func(t *testing.T) { + if dialect.env != "" && os.Getenv(dialect.env) == "" { + t.Skip("set " + dialect.env + " to run this database") + } + db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env)) + + t.Run("square_states_follow_catalog_policy", func(t *testing.T) { + records := []model.Model{ + {ModelName: "square-visible", Status: 1}, + {ModelName: "square-hidden", Status: 0}, + {ModelName: "square-catalog", Status: 1}, + {ModelName: "square-hidden-catalog", Status: 0}, + {ModelName: "square-partial-", NameRule: model.NameRulePrefix, Status: 1}, + {ModelName: "square-partial-hidden", Status: 0}, + {ModelName: "square-hidden-", NameRule: model.NameRulePrefix, Status: 0}, + {ModelName: "square-hidden-override", Status: 1}, + {ModelName: "square-off-", NameRule: model.NameRulePrefix, Status: 0}, + {ModelName: "square-empty", NameRule: model.NameRulePrefix, Status: 1}, + {ModelName: "square-empty-hidden", NameRule: model.NameRulePrefix, Status: 0}, + {ModelName: "-ending", NameRule: model.NameRuleSuffix, Status: 1}, + {ModelName: "square-prefix-", NameRule: model.NameRulePrefix, Status: 0}, + {ModelName: "square-contains", NameRule: model.NameRuleContains, Status: 0}, + } + ids := make([]int, 0, len(records)) + for i := range records { + require.NoError(t, records[i].Insert()) + ids = append(ids, records[i].Id) + } + active := model.Channel{Name: "Square active", Type: 1, Key: "fixture", Group: "default", Status: common.ChannelStatusEnabled, + Models: "square-visible,square-hidden,square-bare,square-partial-on,square-partial-hidden,square-hidden-child,square-hidden-override,square-prefix-ending,square-contains-ending"} + inactive := model.Channel{Name: "Square inactive", Type: 1, Key: "fixture", Group: "default", Status: common.ChannelStatusManuallyDisabled, + Models: "square-disabled,square-partial-off,square-off-child"} + for _, channel := range []*model.Channel{&active, &inactive} { + require.NoError(t, channel.Insert()) + } + t.Cleanup(func() { + require.NoError(t, db.Where("channel_id IN ?", []int{active.Id, inactive.Id}).Delete(&model.Ability{}).Error) + require.NoError(t, db.Where("id IN ?", []int{active.Id, inactive.Id}).Delete(&model.Channel{}).Error) + require.NoError(t, db.Unscoped().Where("id IN ?", ids).Delete(&model.Model{}).Error) + model.RefreshPricing() + }) + var response struct { + Success bool + Data struct{ Items []model.Model } + } + modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=square-&page_size=100", nil, &response) + require.True(t, response.Success) + byName := make(map[string]model.Model) + for _, row := range response.Data.Items { + byName[row.ModelName] = row + } + catalog := make(map[string]bool) + model.RefreshPricing() + for _, row := range model.GetPricing() { + catalog[row.ModelName] = true + } + expected := map[string]model.ModelSquareState{ + "square-visible": model.ModelSquareVisible, + "square-hidden": model.ModelSquareHidden, + "square-catalog": model.ModelSquareUnavailable, + "square-hidden-catalog": model.ModelSquareHidden, + "square-bare": model.ModelSquareVisible, + "square-disabled": model.ModelSquareUnavailable, + "square-partial-": model.ModelSquarePartial, + "square-partial-hidden": model.ModelSquareHidden, + "square-partial-on": model.ModelSquareVisible, + "square-partial-off": model.ModelSquareUnavailable, + "square-hidden-": model.ModelSquarePartial, + "square-hidden-child": model.ModelSquareHidden, + "square-hidden-override": model.ModelSquareVisible, + "square-off-": model.ModelSquareHidden, + "square-off-child": model.ModelSquareHidden, + "square-empty": model.ModelSquareUnavailable, + "square-empty-hidden": model.ModelSquareHidden, + "square-prefix-ending": model.ModelSquareHidden, + "square-contains-ending": model.ModelSquareVisible, + "square-contains": model.ModelSquareVisible, + "square-prefix-": model.ModelSquareHidden, + } + for name, state := range expected { + row, exists := byName[name] + require.True(t, exists, name) + assert.Equal(t, state, row.SquareState, name) + if row.NameRule == model.NameRuleExact { + assert.Equal(t, state == model.ModelSquareVisible, catalog[name], name) + } + } + assert.Zero(t, byName["square-bare"].Id) + assert.Zero(t, byName["square-hidden-child"].Id) + t.Run("filter_square_state_before_pagination", func(t *testing.T) { + type filteredResponse struct { + Success bool + Data struct { + Items []model.Model + Total int + } + } + for _, state := range []model.ModelSquareState{model.ModelSquareVisible, model.ModelSquareUnavailable, model.ModelSquareHidden, model.ModelSquarePartial} { + t.Run(string(state), func(t *testing.T) { + expectedNames := []string{} + for name, expectedState := range expected { + if expectedState == state { + expectedNames = append(expectedNames, name) + } + } + actualNames := []string{} + for page := 1; page <= (len(expectedNames)+1)/2; page++ { + var result filteredResponse + path := fmt.Sprintf("/api/models/search?include_channel_models=true&keyword=square-&square_state=%s&page_size=2&p=%d", state, page) + modelManagementRequest(t, SearchModelsMeta, "GET", path, nil, &result) + require.True(t, result.Success) + assert.Equal(t, len(expectedNames), result.Data.Total) + for _, row := range result.Data.Items { + assert.Equal(t, state, row.SquareState) + actualNames = append(actualNames, row.ModelName) + } + } + assert.ElementsMatch(t, expectedNames, actualNames) + var beyondLastPage filteredResponse + modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=square-&square_state="+string(state)+"&page_size=2&p=99", nil, &beyondLastPage) + require.True(t, beyondLastPage.Success) + assert.Equal(t, len(expectedNames), beyondLastPage.Data.Total) + assert.Empty(t, beyondLastPage.Data.Items) + }) + } + for _, test := range []struct { + name string + handler gin.HandlerFunc + query string + names []string + }{ + {"list", GetAllModelsMeta, "?include_channel_models=true&square_state=visible", []string{"square-visible", "square-bare", "square-partial-on", "square-hidden-override", "square-contains-ending", "square-contains"}}, + {"metadata_only", SearchModelsMeta, "?keyword=square-&square_state=visible", []string{"square-visible", "square-hidden-override", "square-contains"}}, + {"keyword", SearchModelsMeta, "?include_channel_models=true&keyword=square-partial&square_state=hidden", []string{"square-partial-hidden"}}, + {"policy", SearchModelsMeta, "?include_channel_models=true&keyword=square-&square_state=partial&status=disabled", []string{"square-hidden-"}}, + {"vendor_and_sync", SearchModelsMeta, "?include_channel_models=true&keyword=square-&square_state=partial&vendor=0&sync_official=no", []string{"square-hidden-", "square-partial-"}}, + {"no_matches", SearchModelsMeta, "?include_channel_models=true&keyword=square-bare&square_state=hidden", []string{}}, + } { + t.Run(test.name, func(t *testing.T) { + var result filteredResponse + modelManagementRequest(t, test.handler, "GET", "/api/models/"+test.query+"&page_size=100", nil, &result) + require.True(t, result.Success) + names := []string{} + for _, row := range result.Data.Items { + names = append(names, row.ModelName) + } + assert.Equal(t, len(test.names), result.Data.Total) + assert.ElementsMatch(t, test.names, names) + }) + } + for _, handler := range []gin.HandlerFunc{GetAllModelsMeta, SearchModelsMeta} { + var result filteredResponse + recorder := modelManagementRequest(t, handler, "GET", "/api/models/?square_state=unknown", nil, &result) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + assert.False(t, result.Success) + } + for _, query := range []string{"p=-1", "page_size=-1"} { + recorder := modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?square_state=visible&"+query, nil, nil) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + } + var oversizedPage filteredResponse + modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?square_state=visible&keyword=square-&p="+strconv.Itoa(int(^uint(0)>>1)), nil, &oversizedPage) + require.True(t, oversizedPage.Success) + assert.Equal(t, 3, oversizedPage.Data.Total) + assert.Empty(t, oversizedPage.Data.Items) + }) + var detail struct { + Success bool + Data model.Model + } + modelManagementRequest(t, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(records[4].Id)}} + GetModelMeta(c) + }, "GET", "/api/models/"+strconv.Itoa(records[4].Id), nil, &detail) + require.True(t, detail.Success) + assert.Equal(t, model.ModelSquarePartial, detail.Data.SquareState) + }) + t.Run("channel_model_listing", func(t *testing.T) { + exact := model.Model{ModelName: "listing-exact", Status: 1, SyncOfficial: 1} + catalog := model.Model{ModelName: "listing-catalog", Status: 1} + rule := model.Model{ModelName: "listing-rule-", NameRule: model.NameRulePrefix, Status: 1} + for _, item := range []*model.Model{&exact, &catalog, &rule} { + require.NoError(t, item.Insert()) + } + active := model.Channel{Name: "Listing active", Type: 1, Key: "fixture", Models: "listing-exact, listing-new,listing-rule-child,listing-new, ,", Group: "default", Status: common.ChannelStatusEnabled} + inactive := model.Channel{Name: "Listing inactive", Type: 1, Key: "fixture", Models: "listing-new,listing-disabled", Group: "default", Status: common.ChannelStatusManuallyDisabled} + for _, channel := range []*model.Channel{&active, &inactive} { + require.NoError(t, channel.Insert()) + } + t.Cleanup(func() { + require.NoError(t, db.Where("channel_id IN ?", []int{active.Id, inactive.Id}).Delete(&model.Ability{}).Error) + require.NoError(t, db.Where("id IN ?", []int{active.Id, inactive.Id}).Delete(&model.Channel{}).Error) + require.NoError(t, db.Unscoped().Where("model_name LIKE ?", "listing-%").Delete(&model.Model{}).Error) + model.RefreshPricing() + }) + type listingResponse struct { + Success bool + Data struct { + Items []model.Model + Total int + } + } + var response listingResponse + modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=listing-", nil, &response) + require.True(t, response.Success) + require.Equal(t, 6, response.Data.Total) + names := make([]string, 0) + byName := make(map[string]model.Model) + for _, item := range response.Data.Items { + names = append(names, item.ModelName) + byName[item.ModelName] = item + } + assert.Equal(t, []string{"listing-rule-", "listing-catalog", "listing-exact", "listing-disabled", "listing-new", "listing-rule-child"}, names) + assert.True(t, byName[exact.ModelName].HasMetadata) + assert.False(t, byName["listing-new"].HasMetadata) + assert.Zero(t, byName["listing-new"].Id) + assert.Equal(t, 2, byName["listing-new"].ConfiguredChannelCount) + assert.Equal(t, 1, byName["listing-disabled"].ConfiguredChannelCount) + assert.Empty(t, byName["listing-disabled"].BoundChannels) + assert.Zero(t, byName[catalog.ModelName].ConfiguredChannelCount) + for _, tc := range []struct { + query string + total int + names []string + }{ + {"&p=2&page_size=2", 6, []string{"listing-exact", "listing-disabled"}}, + {"&p=9&page_size=2", 6, []string{}}, + {"&status=enabled", 3, []string{"listing-rule-", "listing-catalog", "listing-exact"}}, + {"&sync_official=no", 2, []string{"listing-rule-", "listing-catalog"}}, + {"&vendor=0", 6, []string{"listing-rule-", "listing-catalog", "listing-exact", "listing-disabled", "listing-new", "listing-rule-child"}}, + {"&vendor=999", 0, []string{}}, + } { + var page listingResponse + modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=listing-"+tc.query, nil, &page) + require.True(t, page.Success) + assert.Equal(t, tc.total, page.Data.Total) + actual := make([]string, 0) + for _, item := range page.Data.Items { + actual = append(actual, item.ModelName) + } + assert.Equal(t, tc.names, actual) + } + var legacy listingResponse + modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?keyword=listing-", nil, &legacy) + assert.Equal(t, 3, legacy.Data.Total) + var count int64 + require.NoError(t, db.Model(&model.Model{}).Where("model_name LIKE ?", "listing-%").Count(&count).Error) + assert.EqualValues(t, 3, count) + prices, err := model.GetModelPricingSnapshot([]string{"listing-new"}) + require.NoError(t, err) + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{{ModelName: "listing-new", ExpectedVersion: prices.Entries[0].Version, Pricing: model.PricingValues{"ModelPrice": float64(0)}}})) + t.Cleanup(func() { + snapshot, err := model.GetModelPricingSnapshot([]string{"listing-new"}) + require.NoError(t, err) + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{{ModelName: "listing-new", ExpectedVersion: snapshot.Entries[0].Version, Reset: true}})) + }) + require.NoError(t, db.Model(&model.Model{}).Where("model_name = ?", "listing-new").Count(&count).Error) + assert.Zero(t, count) + for _, price := range model.GetPricing() { + assert.NotEqual(t, catalog.ModelName, price.ModelName) + } + created := model.Model{ModelName: "listing-new", Status: 1} + require.NoError(t, created.Insert()) + var after listingResponse + modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=listing-new", nil, &after) + require.Len(t, after.Data.Items, 1) + assert.Equal(t, created.Id, after.Data.Items[0].Id) + require.NoError(t, created.Delete()) + after = listingResponse{} + modelManagementRequest(t, SearchModelsMeta, "GET", "/api/models/search?include_channel_models=true&keyword=listing-new", nil, &after) + require.Len(t, after.Data.Items, 1) + assert.False(t, after.Data.Items[0].HasMetadata) + require.NoError(t, db.Callback().Query().Before("gorm:query").Register("fail_listing_channels", func(tx *gorm.DB) { + if tx.Statement.Table == "channels" { + tx.AddError(errors.New("channel lookup failed")) + } + })) + var failed listingResponse + modelManagementRequest(t, GetAllModelsMeta, "GET", "/api/models/?include_channel_models=true", nil, &failed) + require.NoError(t, db.Callback().Query().Remove("fail_listing_channels")) + assert.False(t, failed.Success, "a channel lookup failure must not look like an empty configuration") + + }) + + t.Run("pricing_saves_zero_switches_modes_and_rejects_stale_batches", func(t *testing.T) { + before, err := model.GetModelPricingSnapshot([]string{"matrix-priced", "matrix-other"}) + require.NoError(t, err) + changes := []model.ModelPricingChange{ + {ModelName: "matrix-priced", ExpectedVersion: before.EmptyVersion, Pricing: model.PricingValues{"ModelPrice": float64(0), "billing_setting.billing_mode": "ratio"}}, + {ModelName: "matrix-other", ExpectedVersion: before.EmptyVersion, Pricing: model.PricingValues{"ModelRatio": float64(1), "CreateCacheRatio": 1.25}}, + } + require.NoError(t, model.UpdateModelPricing(changes)) + loaded, err := model.GetModelPricingSnapshot([]string{"matrix-priced"}) + require.NoError(t, err) + assert.Equal(t, float64(0), loaded.Entries[0].Effective["ModelPrice"]) + stale := changes[0] + changes[0].ExpectedVersion = loaded.Entries[0].Version + changes[0].Pricing = model.PricingValues{"billing_setting.billing_mode": "tiered_expr", "billing_setting.billing_expr": `tier("base", p * 2 + c * 8 + cr * 0 + cc * 2.5)`, "ModelRatio": float64(1)} + require.NoError(t, model.UpdateModelPricing(changes[:1])) + loaded, err = model.GetModelPricingSnapshot([]string{"matrix-priced", "matrix-other"}) + require.NoError(t, err) + assert.Equal(t, 1.25, loaded.Entries[0].Configured["CreateCacheRatio"]) + assert.Equal(t, "tiered_expr", loaded.Entries[1].Effective["billing_setting.billing_mode"]) + _, oldFixed := loaded.Entries[1].Configured["ModelPrice"] + assert.False(t, oldFixed) + other := model.ModelPricingChange{ModelName: "matrix-other", ExpectedVersion: loaded.Entries[0].Version, Pricing: model.PricingValues{"ModelRatio": float64(9)}} + err = model.UpdateModelPricing([]model.ModelPricingChange{other, stale}) + assert.ErrorIs(t, err, model.ErrModelPricingConflict) + after, err := model.GetModelPricingSnapshot([]string{"matrix-priced", "matrix-other"}) + require.NoError(t, err) + assert.Equal(t, loaded.Entries, after.Entries) + invalid := other + invalid.Pricing = model.PricingValues{"ModelPrice": float64(-1)} + assert.Error(t, model.UpdateModelPricing([]model.ModelPricingChange{invalid})) + // A physical failure after earlier option writes must roll back all + // rows and leave the previously published runtime price intact. + writes := 0 + require.NoError(t, db.Callback().Update().Before("gorm:update").Register("fail_pricing_matrix", func(tx *gorm.DB) { + if tx.Statement.Table == "options" { + writes++ + if writes == 3 { + tx.AddError(errors.New("injected write failure")) + } + } + })) + err = model.UpdateModelPricing([]model.ModelPricingChange{other}) + require.Error(t, err) + require.NoError(t, db.Callback().Update().Remove("fail_pricing_matrix")) + after, err = model.GetModelPricingSnapshot([]string{"matrix-priced", "matrix-other"}) + require.NoError(t, err) + assert.Equal(t, loaded.Entries, after.Entries) + ratio, _, _ := ratio_setting.GetModelRatio("matrix-other") + assert.Equal(t, float64(1), ratio) + // Two saves based on the same version cannot both succeed. + var wg sync.WaitGroup + results := make(chan error, 2) + for _, value := range []float64{3, 4} { + wg.Add(1) + go func(value float64) { + defer wg.Done() + change := other + change.Pricing = model.PricingValues{"ModelRatio": value} + results <- model.UpdateModelPricing([]model.ModelPricingChange{change}) + }(value) + } + wg.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, model.ErrModelPricingConflict) { + conflicts++ + } else { + require.NoError(t, err) + } + } + assert.Equal(t, 1, successes) + assert.Equal(t, 1, conflicts) + }) + t.Run("task_usage_and_builtin_reset", func(t *testing.T) { + snapshot, err := model.GetModelPricingSnapshot([]string{"matrix-task"}) + require.NoError(t, err) + assert.Contains(t, snapshot.Entries[0].UsageSchema, "seconds") + expression := `tier("base", u("seconds") * 0.25)` + change := model.ModelPricingChange{ModelName: "matrix-task", ExpectedVersion: snapshot.Entries[0].Version, Pricing: model.PricingValues{"billing_setting.billing_mode": "tiered_expr", "billing_setting.billing_expr": expression}} + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{change})) + snapshot, err = model.GetModelPricingSnapshot([]string{"matrix-task"}) + require.NoError(t, err) + assert.Equal(t, expression, snapshot.Entries[0].Effective["billing_setting.billing_expr"]) + change.ExpectedVersion = snapshot.Entries[0].Version + change.Pricing["billing_setting.billing_expr"] = `tier("base", u("undeclared") * 1)` + assert.Error(t, model.UpdateModelPricing([]model.ModelPricingChange{change})) + { + const name = "gpt-6-astra" + builtin, exists := billing_setting.GetBuiltinBillingExpr(name) + require.True(t, exists) + before, err := model.GetModelPricingSnapshot([]string{name}) + require.NoError(t, err) + require.Empty(t, before.Entries[0].Configured) + change = model.ModelPricingChange{ModelName: name, ExpectedVersion: before.Entries[0].Version, Pricing: model.PricingValues{"ModelPrice": float64(0)}} + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{change})) + custom, err := model.GetModelPricingSnapshot([]string{name}) + require.NoError(t, err) + assert.Equal(t, float64(0), custom.Entries[0].Effective["ModelPrice"]) + change.ExpectedVersion, change.Pricing, change.Reset = custom.Entries[0].Version, nil, true + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{change})) + reset, err := model.GetModelPricingSnapshot([]string{name}) + require.NoError(t, err) + assert.Empty(t, reset.Entries[0].Configured) + assert.Equal(t, builtin, reset.Entries[0].Effective["billing_setting.billing_expr"]) + } + }) + t.Run("concurrent_import_creates_one_record", func(t *testing.T) { + update := model.MetadataSyncUpdate{MetadataSyncSelection: model.MetadataSyncSelection{ModelName: "matrix-concurrent-import", RecordVersion: model.MetadataRecordVersion(nil, nil, nil), Create: true}, Values: model.MetadataValues{Description: "Imported", Status: 1}} + var wg sync.WaitGroup + results := make(chan error, 2) + for range 2 { + wg.Go(func() { + _, err := model.ApplyMetadataSync([]model.MetadataSyncUpdate{update}, nil) + results <- err + }) + } + wg.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, model.ErrMetadataSyncConflict) { + conflicts++ + } else { + require.NoError(t, err) + } + } + assert.Equal(t, 1, successes) + assert.Equal(t, 1, conflicts) + var count int64 + require.NoError(t, db.Model(&model.Model{}).Where("model_name = ?", update.ModelName).Count(&count).Error) + assert.EqualValues(t, 1, count) + }) + t.Run("metadata_keeps_pricing_and_channel_identity", func(t *testing.T) { + active := model.Channel{Name: "Active route", Type: 1, Status: common.ChannelStatusEnabled} + disabled := model.Channel{Name: "Disabled route", Type: 1, Status: common.ChannelStatusManuallyDisabled} + require.NoError(t, db.Create(&active).Error) + require.NoError(t, db.Create(&disabled).Error) + require.NoError(t, db.Create(&[]model.Ability{ + {Model: "matrix-hidden-unpriced", Group: "available", ChannelId: active.Id, Enabled: true}, + {Model: "matrix-hidden-unpriced", Group: "disabled", ChannelId: disabled.Id, Enabled: true}, + {Model: "matrix-hidden-unpriced", Group: "inactive", ChannelId: active.Id, Enabled: false}, + }).Error) + exact := &model.Model{ModelName: "matrix-hidden-unpriced", Status: 0, SyncOfficial: 0} + require.NoError(t, exact.Insert()) + rule := &model.Model{ModelName: "matrix-hidden-", NameRule: model.NameRulePrefix} + enrichModels([]*model.Model{exact, rule}) + assert.Equal(t, []string{"available"}, exact.EnableGroups) + assert.Equal(t, []model.BoundChannel{{Name: "Active route", Type: 1}}, exact.BoundChannels) + assert.Equal(t, []string{"matrix-hidden-unpriced"}, rule.MatchedModels) + assert.Empty(t, exact.Endpoints, "inferred endpoints must not become stored configuration") + priceBefore, err := model.GetModelPricingSnapshot([]string{"matrix-hidden-unpriced"}) + require.NoError(t, err) + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{{ModelName: exact.ModelName, ExpectedVersion: priceBefore.Entries[0].Version, Pricing: model.PricingValues{"ModelPrice": float64(0)}}})) + exact.ModelName = "matrix-renamed" + exact.Endpoints = `{"openai":{"path":"/v1/chat/completions","method":"POST"}}` + response := modelManagementRequest(t, UpdateModelMeta, http.MethodPut, "/api/models/", exact, nil) + assert.Contains(t, response.Body.String(), `"success":true`) + var reloaded model.Model + require.NoError(t, db.First(&reloaded, exact.Id).Error) + enrichModels([]*model.Model{&reloaded}) + assert.Equal(t, exact.Endpoints, reloaded.Endpoints) + assert.Empty(t, reloaded.BoundChannels) + prices, err := model.GetModelPricingSnapshot([]string{"matrix-hidden-unpriced", "matrix-renamed"}) + require.NoError(t, err) + assert.Equal(t, float64(0), prices.Entries[0].Configured["ModelPrice"]) + assert.Empty(t, prices.Entries[1].Configured) + require.NoError(t, reloaded.Delete()) + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{{ModelName: "matrix-hidden-unpriced", ExpectedVersion: prices.Entries[0].Version, Reset: true}})) + prices, err = model.GetModelPricingSnapshot([]string{"matrix-hidden-unpriced"}) + require.NoError(t, err) + assert.Empty(t, prices.Entries[0].Configured) + var ability model.Ability + require.NoError(t, db.Where("model = ? AND channel_id = ? AND enabled = ?", "matrix-hidden-unpriced", active.Id, true).First(&ability).Error) + }) + t.Run("metadata_preview_selection_versions_and_transaction", func(t *testing.T) { + local := &model.Model{ModelName: "matrix-existing", Description: "Local description", Tags: "keep", Status: 1, SyncOfficial: 1} + require.NoError(t, local.Insert()) + blocked := &model.Model{ModelName: "matrix-blocked", Status: 1, SyncOfficial: 0} + require.NoError(t, blocked.Insert()) + require.NoError(t, db.Create(&model.Ability{Model: "matrix-new", Group: "default", ChannelId: 1, Enabled: true}).Error) + var revision atomic.Int32 + var failVendors atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/api/i18n/zh/newapi/") + var payload any + if strings.HasSuffix(r.URL.Path, "vendors.json") { + if failVendors.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + payload = []upstreamVendor{{Name: "Matrix vendor", Status: 1}} + } else { + payload = []upstreamModel{ + {ModelName: "matrix-existing", Description: fmt.Sprintf("Upstream %d", revision.Load()), VendorName: "Matrix vendor", Tags: "changed", Status: 0, Endpoints: []byte(`{"openai":{"path":"/v1/chat/completions","method":"POST"}}`)}, + {ModelName: "matrix-new", Description: "New model", VendorName: "Matrix vendor", Status: 0, Endpoints: []byte(`{"openai":"/v1/chat/completions"}`)}, + {ModelName: "matrix-blocked", Description: "Do not overwrite", Status: 1}, + {ModelName: "matrix-catalog", Description: "Catalog only", Status: 1}, + } + } + encoded, err := common.Marshal(payload) + require.NoError(t, err) + _, _ = w.Write(encoded) + })) + defer upstream.Close() + t.Setenv("SYNC_UPSTREAM_BASE", upstream.URL) + t.Setenv("SYNC_HTTP_RETRY", "1") + var preview struct { + Success bool + Data struct { + Source metadataSyncSource + Candidates []metadataSyncCandidate + } + } + response := modelManagementRequest(t, SyncUpstreamPreview, "GET", "/api/models/sync_upstream/preview?locale=zh", nil, &preview) + require.Equal(t, http.StatusOK, response.Code) + require.True(t, preview.Success, response.Body.String()) + byName := make(map[string]metadataSyncCandidate) + for _, candidate := range preview.Data.Candidates { + byName[candidate.ModelName] = candidate + } + assert.Equal(t, "site", byName["matrix-new"].Scope) + assert.Equal(t, "catalog", byName["matrix-catalog"].Scope) + assert.Equal(t, "blocked", byName["matrix-blocked"].Kind) + var count int64 + require.NoError(t, db.Model(&model.Vendor{}).Count(&count).Error) + assert.Zero(t, count) + body := map[string]any{"locale": "zh", "source_version": preview.Data.Source.Version, "selections": []model.MetadataSyncSelection{ + {ModelName: "matrix-existing", RecordVersion: byName["matrix-existing"].RecordVersion, Fields: []string{"description", "endpoints"}}, + {ModelName: "matrix-new", RecordVersion: byName["matrix-new"].RecordVersion, Create: true}, + }} + beforePricing, err := model.GetModelPricingSnapshot([]string{"matrix-priced"}) + require.NoError(t, err) + // A failed model insert must also undo the earlier metadata update + // and the newly inserted supplier. + require.NoError(t, db.Callback().Create().Before("gorm:create").Register("fail_metadata_matrix", func(tx *gorm.DB) { + if tx.Statement.Table == "models" { + tx.AddError(errors.New("injected metadata failure")) + } + })) + response = modelManagementRequest(t, SyncUpstreamModels, "POST", "/api/models/sync_upstream", body, nil) + assert.NotEqual(t, http.StatusOK, response.Code) + require.NoError(t, db.Callback().Create().Remove("fail_metadata_matrix")) + var persisted model.Model + require.NoError(t, db.First(&persisted, local.Id).Error) + assert.Equal(t, "Local description", persisted.Description) + require.NoError(t, db.Model(&model.Vendor{}).Count(&count).Error) + assert.Zero(t, count) + var result struct { + Success bool + Data model.MetadataSyncResult + } + response = modelManagementRequest(t, SyncUpstreamModels, "POST", "/api/models/sync_upstream", body, &result) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.True(t, result.Success) + assert.Equal(t, []string{"matrix-new"}, result.Data.CreatedModels) + assert.Equal(t, []string{"Matrix vendor"}, result.Data.CreatedVendors) + require.NoError(t, db.First(&persisted, local.Id).Error) + assert.Equal(t, "Upstream 0", persisted.Description) + assert.Equal(t, "keep", persisted.Tags) + assert.Equal(t, 1, persisted.Status) + assert.Zero(t, persisted.VendorID) + assert.JSONEq(t, `{"openai":{"path":"/v1/chat/completions","method":"POST"}}`, persisted.Endpoints) + var created model.Model + require.NoError(t, db.Where("model_name = ?", "matrix-new").First(&created).Error) + assert.Equal(t, 1, created.SyncOfficial) + assert.Zero(t, created.Status) + afterPricing, err := model.GetModelPricingSnapshot([]string{"matrix-priced"}) + require.NoError(t, err) + assert.Equal(t, beforePricing.Entries, afterPricing.Entries) + response = modelManagementRequest(t, SyncUpstreamModels, "POST", "/api/models/sync_upstream", body, nil) + assert.Equal(t, http.StatusConflict, response.Code) + response = modelManagementRequest(t, SyncUpstreamModels, "POST", "/api/models/sync_upstream", map[string]any{}, nil) + assert.Equal(t, http.StatusBadRequest, response.Code) + body["selections"] = []model.MetadataSyncSelection{{ModelName: "matrix-blocked", RecordVersion: byName["matrix-blocked"].RecordVersion, Fields: []string{"description"}}} + response = modelManagementRequest(t, SyncUpstreamModels, "POST", "/api/models/sync_upstream", body, nil) + assert.Equal(t, http.StatusBadRequest, response.Code) + revision.Add(1) + response = modelManagementRequest(t, SyncUpstreamModels, "POST", "/api/models/sync_upstream", body, nil) + assert.Equal(t, http.StatusConflict, response.Code) + failVendors.Store(true) + modelManagementRequest(t, SyncUpstreamPreview, "GET", "/api/models/sync_upstream/preview?locale=zh", nil, &preview) + assert.False(t, preview.Success) + }) + }) + } +} + +func TestMetadataSyncLocaleAndEndpointValidation(t *testing.T) { + for _, locale := range []struct{ input, expected string }{{"zh", "zh"}, {"zh-CN", "zh"}, {"en", "en"}, {"ja", "ja"}} { + t.Run(locale.input, func(t *testing.T) { + normalized, ok := normalizeLocale(locale.input) + assert.True(t, ok) + assert.Equal(t, locale.expected, normalized) + }) + } + _, valid := normalizeLocale("invalid") + assert.False(t, valid) + for _, endpoint := range []string{`1`, `null`, `{"openai":false}`, `{"openai":{"path":"invalid"}}`} { + assert.Error(t, model.ValidateModelEndpoints(endpoint)) + } +} + +func TestVendorManagementDatabaseMatrix(t *testing.T) { + for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} { + t.Run(dialect.kind, func(t *testing.T) { + if dialect.env != "" && os.Getenv(dialect.env) == "" { + t.Skip("set " + dialect.env) + } + db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env)) + + t.Run("pricing_reads_keep_default_brands_without_writing_vendors", func(t *testing.T) { + channel := model.Channel{Name: "Vendor fixture", Type: 1, Status: common.ChannelStatusEnabled} + require.NoError(t, db.Create(&channel).Error) + require.NoError(t, db.Create(&model.Ability{Model: "gemini-vendor-fixture", Group: "default", ChannelId: channel.Id, Enabled: true}).Error) + model.RefreshPricing() + model.GetPricing() + vendors := model.GetVendors() + require.Len(t, vendors, 1) + assert.Equal(t, "Google", vendors[0].Name) + assert.Equal(t, "Gemini.Color", vendors[0].Icon) + assert.Negative(t, vendors[0].ID) + var count int64 + require.NoError(t, db.Model(&model.Vendor{}).Count(&count).Error) + assert.Zero(t, count) + saved := model.Vendor{Name: "Google", Icon: "Gemini.Color"} + require.NoError(t, saved.Insert()) + assert.Equal(t, saved.Id, model.GetVendors()[0].ID) + require.NoError(t, saved.Delete()) + assert.Equal(t, vendors[0].ID, model.GetVendors()[0].ID) + require.NoError(t, db.Model(&model.Vendor{}).Count(&count).Error) + assert.Zero(t, count, "refresh must not recreate a deleted vendor") + }) + t.Run("metadata_ownership_preview_merge_delete_and_rollback", func(t *testing.T) { + source := model.Vendor{Name: " Vendor Source ", Icon: "Gemini.Color"} + target := model.Vendor{Name: "Vendor Target", Description: "Keep target", Icon: "OpenAI"} + require.NoError(t, source.Insert()) + require.NoError(t, target.Insert()) + assert.Equal(t, "Vendor Source", source.Name) + assert.Error(t, (&model.Vendor{Name: "vendor source"}).Insert()) + assert.Error(t, (&model.Vendor{Name: " "}).Insert()) + require.NoError(t, db.Model(&model.Vendor{}).Where("id = ?", source.Id).Update("status", 0).Error) + loaded, err := model.GetVendorByID(source.Id) + require.NoError(t, err) + staleVersion := loaded.Version + edit := model.Vendor{Id: source.Id, Name: source.Name, Description: "Updated source", Icon: source.Icon, Version: loaded.Version} + require.NoError(t, edit.Update()) + updated, err := model.GetVendorByID(source.Id) + require.NoError(t, err) + assert.Equal(t, loaded.CreatedTime, updated.CreatedTime) + assert.Zero(t, updated.Status) + edit.Version = staleVersion + assert.ErrorIs(t, edit.Update(), model.ErrVendorConflict) + + one := model.Model{ModelName: "vendor-model-one", VendorID: source.Id, Icon: "Custom", Description: "Preserve description", Status: 0, SyncOfficial: 0} + rule := model.Model{ModelName: "vendor-rule-", VendorID: source.Id, NameRule: model.NameRulePrefix, Status: 1, SyncOfficial: 1} + require.NoError(t, one.Insert()) + require.NoError(t, rule.Insert()) + priceBefore, err := model.GetModelPricingSnapshot([]string{one.ModelName}) + require.NoError(t, err) + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{{ModelName: one.ModelName, ExpectedVersion: priceBefore.Entries[0].Version, Pricing: model.PricingValues{"ModelPrice": float64(0.25)}}})) + priceBefore, err = model.GetModelPricingSnapshot([]string{one.ModelName}) + require.NoError(t, err) + channel := model.Channel{Name: "Unchanged vendor channel", Type: 1, Status: common.ChannelStatusEnabled} + require.NoError(t, db.Create(&channel).Error) + ability := model.Ability{Model: one.ModelName, Group: "default", ChannelId: channel.Id, Enabled: true} + require.NoError(t, db.Create(&ability).Error) + linked, total, err := model.SearchVendors("", 0, 20, "linked") + require.NoError(t, err) + require.Len(t, linked, 1) + assert.EqualValues(t, 1, total) + assert.EqualValues(t, 2, linked[0].ModelCount) + unlinked, _, err := model.SearchVendors("Vendor Target", 0, 20, "unlinked") + require.NoError(t, err) + require.Len(t, unlinked, 1) + var references *model.VendorReferenceError + err = model.DeleteVendors([]int{source.Id, target.Id}) + require.ErrorAs(t, err, &references) + assert.EqualValues(t, 2, references.Counts[source.Id]) + _, err = model.GetVendorByID(target.Id) + require.NoError(t, err, "bulk delete must not partially delete unreferenced vendors") + var response struct { + Success bool + Code string + ReferenceCounts map[int]int64 `json:"reference_counts"` + } + recorder := modelManagementRequest(t, PreviewVendorOperation, http.MethodPost, "/api/vendors/operations/preview", model.VendorOperation{Action: "delete", VendorIDs: []int{source.Id}}, &response) + assert.Equal(t, http.StatusConflict, recorder.Code) + assert.Equal(t, "VENDOR_REFERENCED", response.Code) + assert.EqualValues(t, 2, response.ReferenceCounts[source.Id]) + + disappearing := model.Vendor{Name: "Preview target"} + require.NoError(t, disappearing.Insert()) + staleAssignment := model.VendorOperation{Action: "assign", ModelIDs: []int{one.Id}, TargetVendorID: disappearing.Id} + stalePreview, err := model.PreviewVendorOperation(staleAssignment) + require.NoError(t, err) + staleAssignment.ExpectedVersion = stalePreview.Version + require.NoError(t, disappearing.Delete()) + recorder = modelManagementRequest(t, ApplyVendorOperation, http.MethodPost, "/api/vendors/operations", staleAssignment, &response) + assert.Equal(t, http.StatusConflict, recorder.Code) + assert.Equal(t, "VENDOR_CONFLICT", response.Code) + + assign := model.VendorOperation{Action: "assign", ModelIDs: []int{one.Id}, TargetVendorID: target.Id} + preview, err := model.PreviewVendorOperation(assign) + require.NoError(t, err) + require.Len(t, preview.Models, 1) + assign.ExpectedVersion = preview.Version + one.Description = "Updated in the same timestamp" + require.NoError(t, db.Model(&model.Model{}).Where("id = ?", one.Id).Update("description", one.Description).Error) + _, err = model.ApplyVendorOperation(assign) + assert.ErrorIs(t, err, model.ErrVendorConflict) + preview, err = model.PreviewVendorOperation(assign) + require.NoError(t, err) + assign.ExpectedVersion = preview.Version + target.Description = "New target description" + require.NoError(t, target.Update()) + _, err = model.ApplyVendorOperation(assign) + assert.ErrorIs(t, err, model.ErrVendorConflict) + preview, err = model.PreviewVendorOperation(assign) + require.NoError(t, err) + assign.ExpectedVersion = preview.Version + result, err := model.ApplyVendorOperation(assign) + require.NoError(t, err) + assert.Equal(t, []int{one.Id}, result.UpdatedModels) + var after model.Model + after = model.Model{} + require.NoError(t, db.First(&after, one.Id).Error) + assert.Equal(t, target.Id, after.VendorID) + assert.Equal(t, one.Description, after.Description) + assert.Equal(t, one.Icon, after.Icon) + assert.Equal(t, one.Status, after.Status) + after = model.Model{} + require.NoError(t, db.First(&after, rule.Id).Error) + assert.Equal(t, source.Id, after.VendorID) + assign.TargetVendorID = 0 + preview, err = model.PreviewVendorOperation(assign) + require.NoError(t, err) + assign.ExpectedVersion = preview.Version + _, err = model.ApplyVendorOperation(assign) + require.NoError(t, err) + after = model.Model{} + require.NoError(t, db.First(&after, one.Id).Error) + assert.Zero(t, after.VendorID) + after.VendorID = -1001 + assert.Error(t, after.Update(), "display-only vendors cannot become stored references") + + merge := model.VendorOperation{Action: "merge", VendorIDs: []int{source.Id}, TargetVendorID: target.Id} + preview, err = model.PreviewVendorOperation(merge) + require.NoError(t, err) + merge.ExpectedVersion = preview.Version + require.NoError(t, db.Callback().Delete().Before("gorm:delete").Register("vendor_delete_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "vendors" { + tx.AddError(errors.New("injected vendor delete failure")) + } + })) + _, err = model.ApplyVendorOperation(merge) + require.Error(t, err) + require.NoError(t, db.Callback().Delete().Remove("vendor_delete_failure")) + after = model.Model{} + require.NoError(t, db.First(&after, rule.Id).Error) + assert.Equal(t, source.Id, after.VendorID, "ownership updates roll back when deletion fails") + _, err = model.GetVendorByID(source.Id) + require.NoError(t, err) + _, err = model.ApplyVendorOperation(merge) + require.NoError(t, err) + _, err = model.GetVendorByID(source.Id) + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + retained, err := model.GetVendorByID(target.Id) + require.NoError(t, err) + assert.Equal(t, target.Description, retained.Description) + assert.Equal(t, "OpenAI", retained.Icon) + assert.EqualValues(t, 1, retained.ModelCount) + after = one + after.VendorID = source.Id + assert.Error(t, after.Update(), "deleted vendors cannot acquire new references") + priceAfter, err := model.GetModelPricingSnapshot([]string{one.ModelName}) + require.NoError(t, err) + assert.Equal(t, priceBefore.Entries[0], priceAfter.Entries[0], "assignment and merge preserve model pricing") + var retainedChannel model.Channel + require.NoError(t, db.First(&retainedChannel, channel.Id).Error) + assert.Equal(t, channel.Name, retainedChannel.Name) + assert.Equal(t, channel.Status, retainedChannel.Status) + var retainedAbility model.Ability + require.NoError(t, db.Where("model = ? AND channel_id = ?", one.ModelName, channel.Id).First(&retainedAbility).Error) + assert.Equal(t, ability.Group, retainedAbility.Group) + assert.Equal(t, ability.Enabled, retainedAbility.Enabled) + }) + t.Run("concurrent_create_and_delete_never_orphan_model", func(t *testing.T) { + vendor := model.Vendor{Name: "Concurrent owner"} + require.NoError(t, vendor.Insert()) + var createErr, deleteErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + createErr = (&model.Model{ModelName: "concurrent-owned-model", VendorID: vendor.Id}).Insert() + }() + go func() { defer wg.Done(); deleteErr = vendor.Delete() }() + wg.Wait() + if createErr == nil { + require.Error(t, deleteErr) + } else { + require.NoError(t, deleteErr) + } + var models []model.Model + require.NoError(t, db.Where("model_name = ?", "concurrent-owned-model").Find(&models).Error) + if len(models) != 0 { + _, err := model.GetVendorByID(models[0].VendorID) + require.NoError(t, err) + } + }) + }) + } +} + +func TestModelDeletionDatabaseMatrix(t *testing.T) { + for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} { + t.Run(dialect.kind, func(t *testing.T) { + if dialect.env != "" && os.Getenv(dialect.env) == "" { + t.Skip("set " + dialect.env + " to run this database") + } + db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env)) + + var response struct { + Success bool + Data model.ModelDeleteResult + } + metadataOnly := model.Model{ModelName: "metadata-only", Status: 1} + require.NoError(t, metadataOnly.Insert()) + channel := model.Channel{Name: "Retained channel", Type: 1, Key: "fixture-key", Models: metadataOnly.ModelName, Group: "default", Status: common.ChannelStatusEnabled} + require.NoError(t, db.Create(&channel).Error) + require.NoError(t, channel.UpdateAbilities(db)) + recorder := modelManagementRequest(t, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(metadataOnly.Id)}} + DeleteModelMeta(c) + }, http.MethodDelete, "/api/models/"+strconv.Itoa(metadataOnly.Id), nil, &response) + require.True(t, response.Success, recorder.Body.String()) + assert.Equal(t, model.ModelDeleteResult{DeletedCount: 1}, response.Data) + var retained model.Channel + require.NoError(t, db.First(&retained, channel.Id).Error) + assert.Equal(t, channel.Models, retained.Models) + var count int64 + require.NoError(t, db.Model(&model.Ability{}).Where("channel_id = ?", channel.Id).Count(&count).Error) + assert.EqualValues(t, 1, count) + + for _, rule := range []int{model.NameRuleExact, model.NameRulePrefix, model.NameRuleContains, model.NameRuleSuffix} { + t.Run(fmt.Sprintf("rule_%d_exact_names_only_atomic_and_cached", rule), func(t *testing.T) { + name := fmt.Sprintf("delete-rule-%d", rule) + first := model.Model{ModelName: name, NameRule: rule, Status: 1} + second := model.Model{ModelName: name + "-second", Status: 1} + require.NoError(t, first.Insert()) + require.NoError(t, second.Insert()) + mapping := `{"` + name + `":"upstream-name"}` + priority, weight := int64(7), uint(9) + channels := []model.Channel{ + {Name: "Enabled", Type: 1, Key: "fixture-key", Models: name + "," + name + "-keep," + second.ModelName, Group: "default,vip", Status: common.ChannelStatusEnabled, ModelMapping: &mapping, Priority: &priority, Weight: &weight}, + {Name: "Disabled", Type: 1, Models: name + ",prefix-" + name, Group: "disabled-group", Status: common.ChannelStatusManuallyDisabled}, + {Name: "Last model", Type: 1, Models: name, Group: "last-model-group", Status: common.ChannelStatusEnabled}, + {Name: "Case-sensitive name", Type: 1, Models: strings.ToUpper(name), Group: "case-group", Status: common.ChannelStatusEnabled}, + } + for i := range channels { + require.NoError(t, db.Create(&channels[i]).Error) + require.NoError(t, channels[i].UpdateAbilities(db)) + } + common.MemoryCacheEnabled = true + model.InitChannelCache() + cached, err := model.GetRandomSatisfiedChannel("default", name, 0, nil) + require.NoError(t, err) + require.NotNil(t, cached) + baseline, err := model.GetModelPricingSnapshot([]string{name}) + require.NoError(t, err) + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{{ModelName: name, ExpectedVersion: baseline.EmptyVersion, Pricing: model.PricingValues{"ModelPrice": float64(2)}}})) + pricingBefore, err := model.GetModelPricingSnapshot([]string{name, second.ModelName}) + require.NoError(t, err) + if rule != model.NameRuleExact { + _, err := model.DeleteModelMetadata([]int{first.Id, second.Id}, true, true) + require.EqualError(t, err, "only exact-match models can be removed from channels") + after, err := model.GetModelPricingSnapshot([]string{name, second.ModelName}) + require.NoError(t, err) + assert.Equal(t, pricingBefore, after) + require.NoError(t, db.Model(&model.Model{}).Where("id IN ?", []int{first.Id, second.Id}).Count(&count).Error) + assert.EqualValues(t, 2, count) + for _, original := range channels { + var after model.Channel + require.NoError(t, db.First(&after, original.Id).Error) + assert.Equal(t, original, after) + } + return + } + body := map[string]any{"model_ids": []int{first.Id, second.Id, first.Id}, "remove_from_channels": true} + // A failure at the final metadata delete must undo every earlier + // channel/ability write and keep the published cache intact. + require.NoError(t, db.Callback().Delete().Before("gorm:delete").Register("fail_model_deletion", func(tx *gorm.DB) { + if tx.Statement.Table == "models" { + tx.AddError(errors.New("injected model deletion failure")) + } + })) + result, err := model.DeleteModelMetadata([]int{first.Id, second.Id}, true, false) + require.Error(t, err) + assert.Zero(t, result) + require.NoError(t, db.Callback().Delete().Remove("fail_model_deletion")) + for _, original := range channels { + var after model.Channel + require.NoError(t, db.First(&after, original.Id).Error) + assert.Equal(t, original, after) + } + cached, err = model.GetRandomSatisfiedChannel("default", name, 0, nil) + require.NoError(t, err) + require.NotNil(t, cached) + recorder := modelManagementRequest(t, BatchDeleteModelMeta, http.MethodPost, "/api/models/delete", body, &response) + require.True(t, response.Success, recorder.Body.String()) + assert.Equal(t, model.ModelDeleteResult{DeletedCount: 2, UpdatedChannels: 3}, response.Data) + for i, original := range channels { + var after model.Channel + require.NoError(t, db.First(&after, original.Id).Error) + original.Models = []string{name + "-keep", "prefix-" + name, "", strings.ToUpper(name)}[i] + assert.Equal(t, original, after, "only the model list changes") + } + var abilities []model.Ability + require.NoError(t, db.Where("channel_id IN ?", []int{channels[0].Id, channels[1].Id, channels[2].Id, channels[3].Id}).Find(&abilities).Error) + assert.Len(t, abilities, 4) + for _, ability := range abilities { + assert.NotEqual(t, name, ability.Model) + assert.NotEqual(t, second.ModelName, ability.Model) + assert.NotEmpty(t, ability.Model) + if ability.ChannelId == channels[0].Id { + assert.Equal(t, &priority, ability.Priority) + assert.Equal(t, weight, ability.Weight) + assert.True(t, ability.Enabled) + } + if ability.ChannelId == channels[1].Id { + assert.False(t, ability.Enabled) + } + } + for _, group := range []string{"default", "vip", "last-model-group"} { + cached, _ = model.GetRandomSatisfiedChannel(group, name, 0, nil) + assert.Nil(t, cached) + } + cached, err = model.GetRandomSatisfiedChannel("default", name+"-keep", 0, nil) + require.NoError(t, err) + require.NotNil(t, cached) + pricingAfter, err := model.GetModelPricingSnapshot([]string{name, second.ModelName}) + require.NoError(t, err) + assert.Equal(t, pricingBefore, pricingAfter) + require.NoError(t, db.Model(&model.Model{}).Where("id IN ?", []int{first.Id, second.Id}).Count(&count).Error) + assert.Zero(t, count) + _, err = model.DeleteModelMetadata([]int{first.Id}, true, false) + assert.Error(t, err, "stale selections cannot delete newly created records") + }) + } + for _, removeChannels := range []bool{false, true} { + t.Run(fmt.Sprintf("pricing_removal_channels_%t", removeChannels), func(t *testing.T) { + name := fmt.Sprintf("remove-pricing-%t", removeChannels) + metadata := model.Model{ModelName: name, NameRule: model.NameRuleExact, Status: 1} + require.NoError(t, metadata.Insert()) + keep := name + "-keep" + channel := model.Channel{Name: "Independent pricing removal", Type: 1, Models: name + "," + keep, Group: "pricing-removal", Status: common.ChannelStatusEnabled} + require.NoError(t, db.Create(&channel).Error) + require.NoError(t, channel.UpdateAbilities(db)) + model.InitChannelCache() + baseline, err := model.GetModelPricingSnapshot([]string{name, keep}) + require.NoError(t, err) + pricing := model.PricingValues{"ModelRatio": float64(1), "ModelPrice": float64(0), "CompletionRatio": float64(2), "CacheRatio": float64(0.1), "CreateCacheRatio": float64(1.25), "ImageRatio": float64(3), "AudioRatio": float64(4), "AudioCompletionRatio": float64(5), "billing_setting.billing_mode": "tiered_expr", "billing_setting.billing_expr": `tier("base", p * 2 + c * 4)`} + require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{ + {ModelName: name, ExpectedVersion: baseline.EmptyVersion, Pricing: pricing}, + {ModelName: keep, ExpectedVersion: baseline.EmptyVersion, Pricing: model.PricingValues{"ModelPrice": float64(9)}}, + })) + before, err := model.GetModelPricingSnapshot([]string{name, keep}) + require.NoError(t, err) + body := map[string]any{"model_ids": []int{metadata.Id}, "remove_from_channels": removeChannels, "remove_pricing": true} + for _, single := range []bool{false, true} { + recorder := modelManagementRequest(t, func(c *gin.Context) { + c.Set("role", common.RoleAdminUser) + if single { + c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(metadata.Id)}} + DeleteModelMeta(c) + } else { + BatchDeleteModelMeta(c) + } + }, http.MethodPost, "/api/models/delete?remove_pricing=true", body, nil) + assert.Equal(t, http.StatusForbidden, recorder.Code, "pricing permissions cannot be bypassed through deletion") + } + updates := 0 + require.NoError(t, db.Callback().Update().Before("gorm:update").Register("fail_deleted_pricing", func(tx *gorm.DB) { + if tx.Statement.Table == "options" { + updates++ + if updates == 3 { + tx.AddError(errors.New("injected pricing deletion failure")) + } + } + })) + _, err = model.DeleteModelMetadata([]int{metadata.Id}, removeChannels, true) + require.Error(t, err) + require.NoError(t, db.Callback().Update().Remove("fail_deleted_pricing")) + after, err := model.GetModelPricingSnapshot([]string{name, keep}) + require.NoError(t, err) + assert.Equal(t, before, after, "partial option writes roll back") + assert.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode(name), "failed deletion must not publish new runtime pricing") + var retained model.Model + require.NoError(t, db.First(&retained, metadata.Id).Error) + var channelAfter model.Channel + require.NoError(t, db.First(&channelAfter, channel.Id).Error) + assert.Equal(t, channel.Models, channelAfter.Models) + _, err = model.DeleteModelMetadata([]int{metadata.Id, 999999}, removeChannels, true) + assert.Error(t, err, "a missing model aborts the whole batch") + recorder := modelManagementRequest(t, BatchDeleteModelMeta, http.MethodPost, "/api/models/delete", body, &response) + require.True(t, response.Success, recorder.Body.String()) + after, err = model.GetModelPricingSnapshot([]string{name, keep}) + require.NoError(t, err) + assert.Empty(t, after.Entries[0].Configured) + assert.Equal(t, before.Entries[1], after.Entries[1], "name rules do not expand pricing deletion") + assert.Equal(t, billing_setting.BillingModeRatio, billing_setting.GetBillingMode(name)) + _, hasExpr := billing_setting.GetBillingExpr(name) + assert.False(t, hasExpr) + require.NoError(t, db.First(&channelAfter, channel.Id).Error) + expectedModels := channel.Models + if removeChannels { + expectedModels = keep + } + assert.Equal(t, expectedModels, channelAfter.Models) + require.NoError(t, db.Model(&model.Model{}).Where("id = ?", metadata.Id).Count(&count).Error) + assert.Zero(t, count) + }) + } + for _, ids := range [][]int{nil, {0}, {-1}, make([]int, 1001)} { + _, err := model.DeleteModelMetadata(ids, true, false) + assert.Error(t, err) + } + }) + } +} diff --git a/controller/model_meta.go b/controller/model_meta.go index c3d9954677e7..593d79cca3e8 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -1,13 +1,12 @@ package controller import ( - "encoding/json" + "net/http" "sort" "strconv" "strings" "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" @@ -15,48 +14,63 @@ import ( // GetAllModelsMeta 获取模型列表(分页) func GetAllModelsMeta(c *gin.Context) { - - pageInfo := common.GetPageQuery(c) - status := c.Query("status") - syncOfficial := c.Query("sync_official") - modelsMeta, total, err := model.SearchModels("", "", status, syncOfficial, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) - if err != nil { - common.ApiError(c, err) - return - } - // 批量填充附加字段,提升列表接口性能 - enrichModels(modelsMeta) - - // 统计供应商计数(全部数据,不受分页影响) - vendorCounts, _ := model.GetVendorModelCounts() - - pageInfo.SetTotal(int(total)) - pageInfo.SetItems(modelsMeta) - common.ApiSuccess(c, gin.H{ - "items": modelsMeta, - "total": total, - "page": pageInfo.GetPage(), - "page_size": pageInfo.GetPageSize(), - "vendor_counts": vendorCounts, - }) + listModelsMeta(c, "", "") } // SearchModelsMeta 搜索模型列表 func SearchModelsMeta(c *gin.Context) { + listModelsMeta(c, c.Query("keyword"), c.Query("vendor")) +} - keyword := c.Query("keyword") - vendor := c.Query("vendor") - status := c.Query("status") - syncOfficial := c.Query("sync_official") - pageInfo := common.GetPageQuery(c) +func listModelsMeta(c *gin.Context, keyword, vendor string) { + squareState := model.ModelSquareState(c.Query("square_state")) + switch squareState { + case "", model.ModelSquareVisible, model.ModelSquareUnavailable, model.ModelSquareHidden, model.ModelSquarePartial: + default: + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid model square state"}) + return + } - modelsMeta, total, err := model.SearchModels(keyword, vendor, status, syncOfficial, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + pageInfo := common.GetPageQuery(c) + if squareState != "" && (pageInfo.GetPage() < 1 || pageInfo.GetPageSize() < 1) { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid pagination"}) + return + } + offset, limit := pageInfo.GetStartIdx(), pageInfo.GetPageSize() + if squareState != "" { + // Visibility depends on live channels and metadata rules. Filter the + // enriched candidate set before counting and paginating the results. + offset, limit = 0, -1 + } + search := model.SearchModels + if c.Query("include_channel_models") == "true" { + search = model.SearchModelsWithChannels + } + modelsMeta, total, err := search(keyword, vendor, c.Query("status"), c.Query("sync_official"), offset, limit) if err != nil { common.ApiError(c, err) return } - // 批量填充附加字段,提升列表接口性能 - enrichModels(modelsMeta) + if err := enrichModels(modelsMeta); err != nil { + common.ApiError(c, err) + return + } + if squareState != "" { + filtered := make([]*model.Model, 0, len(modelsMeta)) + for _, metadata := range modelsMeta { + if metadata.SquareState == squareState { + filtered = append(filtered, metadata) + } + } + total = int64(len(filtered)) + start := len(filtered) + if pageInfo.GetPage()-1 <= len(filtered)/pageInfo.GetPageSize() { + start = (pageInfo.GetPage() - 1) * pageInfo.GetPageSize() + } + end := min(start+pageInfo.GetPageSize(), len(filtered)) + modelsMeta = filtered[start:end] + } + vendorCounts, _ := model.GetVendorModelCounts() pageInfo.SetTotal(int(total)) pageInfo.SetItems(modelsMeta) @@ -82,7 +96,10 @@ func GetModelMeta(c *gin.Context) { common.ApiError(c, err) return } - enrichModels([]*model.Model{&m}) + if err := enrichModels([]*model.Model{&m}); err != nil { + common.ApiError(c, err) + return + } common.ApiSuccess(c, &m) } @@ -97,6 +114,10 @@ func CreateModelMeta(c *gin.Context) { common.ApiErrorMsg(c, "模型名称不能为空") return } + if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil { + common.ApiError(c, err) + return + } // 名称冲突检查 if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil { common.ApiError(c, err) @@ -111,6 +132,7 @@ func CreateModelMeta(c *gin.Context) { return } model.RefreshPricing() + m.HasMetadata = m.Id > 0 common.ApiSuccess(c, &m) } @@ -129,12 +151,24 @@ func UpdateModelMeta(c *gin.Context) { } if statusOnly { + if m.Status != 0 && m.Status != 1 { + common.ApiErrorMsg(c, "invalid catalog visibility") + return + } // 只更新状态,防止误清空其他字段 if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil { common.ApiError(c, err) return } } else { + if strings.TrimSpace(m.ModelName) == "" { + common.ApiErrorMsg(c, "模型名称不能为空") + return + } + if err := model.ValidateMetadataValues(model.MetadataValues{Endpoints: m.Endpoints, Status: m.Status, NameRule: m.NameRule}); err != nil { + common.ApiError(c, err) + return + } // 名称冲突检查 if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil { common.ApiError(c, err) @@ -150,6 +184,7 @@ func UpdateModelMeta(c *gin.Context) { } } model.RefreshPricing() + m.HasMetadata = m.Id > 0 common.ApiSuccess(c, &m) } @@ -161,179 +196,142 @@ func DeleteModelMeta(c *gin.Context) { common.ApiError(c, err) return } - if err := model.DB.Delete(&model.Model{}, id).Error; err != nil { + removeFromChannels, err := strconv.ParseBool(c.DefaultQuery("remove_from_channels", "false")) + if err != nil { common.ApiError(c, err) return } - model.RefreshPricing() - common.ApiSuccess(c, nil) + removePricing, err := strconv.ParseBool(c.DefaultQuery("remove_pricing", "false")) + if err != nil { + common.ApiError(c, err) + return + } + if removePricing && c.GetInt("role") != common.RoleRootUser { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Model pricing is managed by a super administrator."}) + return + } + result, err := model.DeleteModelMetadata([]int{id}, removeFromChannels, removePricing) + if err != nil { + common.ApiError(c, err) + return + } + recordManageAudit(c, "model.delete", map[string]any{"model_ids": []int{id}, "remove_from_channels": removeFromChannels, "remove_pricing": removePricing, "updated_channels": result.UpdatedChannels}) + common.ApiSuccess(c, result) } -// enrichModels 批量填充附加信息:端点、渠道、分组、计费类型,避免 N+1 查询 -func enrichModels(models []*model.Model) { - if len(models) == 0 { +func BatchDeleteModelMeta(c *gin.Context) { + var request struct { + ModelIDs []int `json:"model_ids"` + RemoveFromChannels bool `json:"remove_from_channels"` + RemovePricing bool `json:"remove_pricing"` + } + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + common.ApiError(c, err) + return + } + if request.RemovePricing && c.GetInt("role") != common.RoleRootUser { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Model pricing is managed by a super administrator."}) + return + } + result, err := model.DeleteModelMetadata(request.ModelIDs, request.RemoveFromChannels, request.RemovePricing) + if err != nil { + common.ApiError(c, err) return } + recordManageAudit(c, "model.delete_batch", map[string]any{"model_ids": request.ModelIDs, "remove_from_channels": request.RemoveFromChannels, "remove_pricing": request.RemovePricing, "updated_channels": result.UpdatedChannels}) + common.ApiSuccess(c, result) +} - // 1) 拆分精确与规则匹配 - exactNames := make([]string, 0) - exactIdx := make(map[string][]int) // modelName -> indices in models - ruleIndices := make([]int, 0) - for i, m := range models { - if m == nil { +// enrichModels keeps configured endpoints intact and derives connections from +// enabled routes, including hidden or unpriced models absent from the catalog. +func enrichModels(models []*model.Model) error { + if len(models) == 0 { + return nil + } + configured, err := model.GetConfiguredModelChannels() + if err != nil { + return err + } + for _, metadata := range models { + if metadata == nil { continue } - if m.NameRule == model.NameRuleExact { - exactNames = append(exactNames, m.ModelName) - exactIdx[m.ModelName] = append(exactIdx[m.ModelName], i) - } else { - ruleIndices = append(ruleIndices, i) - } - } - - // 2) 批量查询精确模型的绑定渠道 - channelsByModel, _ := model.GetBoundChannelsByModelsMap(exactNames) - - // 3) 精确模型:端点从缓存、渠道批量映射、分组/计费类型从缓存 - for name, indices := range exactIdx { - chs := channelsByModel[name] - for _, idx := range indices { - mm := models[idx] - if mm.Endpoints == "" { - eps := model.GetModelSupportEndpointTypes(mm.ModelName) - if b, err := json.Marshal(eps); err == nil { - mm.Endpoints = string(b) + metadata.HasMetadata = metadata.Id > 0 + channelIDs := make(map[int]struct{}) + for name, ids := range configured { + if metadata.MatchesName(name) { + for _, id := range ids { + channelIDs[id] = struct{}{} } } - mm.BoundChannels = chs - mm.EnableGroups = model.GetModelEnableGroups(mm.ModelName) - mm.QuotaTypes = model.GetModelQuotaTypes(mm.ModelName) } + metadata.ConfiguredChannelCount = len(channelIDs) } - - if len(ruleIndices) == 0 { - return + connections, err := model.GetModelConnections() + if err != nil { + return err } - - // 4) 一次性读取定价缓存,内存匹配所有规则模型 - pricings := model.GetPricing() - - // 为全部规则模型收集匹配名集合、端点并集、分组并集、配额集合 - matchedNamesByIdx := make(map[int][]string) - endpointSetByIdx := make(map[int]map[constant.EndpointType]struct{}) - groupSetByIdx := make(map[int]map[string]struct{}) - quotaSetByIdx := make(map[int]map[int]struct{}) - - for _, p := range pricings { - for _, idx := range ruleIndices { - mm := models[idx] - var matched bool - switch mm.NameRule { - case model.NameRulePrefix: - matched = strings.HasPrefix(p.ModelName, mm.ModelName) - case model.NameRuleSuffix: - matched = strings.HasSuffix(p.ModelName, mm.ModelName) - case model.NameRuleContains: - matched = strings.Contains(p.ModelName, mm.ModelName) - } - if !matched { + if err := model.FillModelSquareStates(models, configured, connections); err != nil { + return err + } + for _, metadata := range models { + if metadata == nil { + continue + } + channels := make(map[int]model.BoundChannel) + groups := make(map[string]bool) + names := make(map[string]bool) + endpoints := make(map[string]bool) + quotas := make(map[int]bool) + for _, connection := range connections { + name := connection.Model + if !metadata.MatchesName(name) { continue } - matchedNamesByIdx[idx] = append(matchedNamesByIdx[idx], p.ModelName) - - es := endpointSetByIdx[idx] - if es == nil { - es = make(map[constant.EndpointType]struct{}) - endpointSetByIdx[idx] = es - } - for _, et := range p.SupportedEndpointTypes { - es[et] = struct{}{} - } - - gs := groupSetByIdx[idx] - if gs == nil { - gs = make(map[string]struct{}) - groupSetByIdx[idx] = gs + names[name] = true + groups[connection.Group] = true + channels[connection.ChannelId] = model.BoundChannel{Name: connection.ChannelName, Type: connection.ChannelType} + for _, endpoint := range model.GetModelSupportEndpointTypes(name) { + endpoints[string(endpoint)] = true } - for _, g := range p.EnableGroup { - gs[g] = struct{}{} + for _, quota := range model.GetModelQuotaTypes(name) { + quotas[quota] = true } - - qs := quotaSetByIdx[idx] - if qs == nil { - qs = make(map[int]struct{}) - quotaSetByIdx[idx] = qs - } - qs[p.QuotaType] = struct{}{} } - } - - // 5) 汇总所有匹配到的模型名称,批量查询一次渠道 - allMatchedSet := make(map[string]struct{}) - for _, names := range matchedNamesByIdx { - for _, n := range names { - allMatchedSet[n] = struct{}{} + metadata.BoundChannels = nil + metadata.EnableGroups = nil + metadata.SupportedEndpoints = nil + metadata.QuotaTypes = nil + metadata.MatchedModels = nil + for _, channel := range channels { + metadata.BoundChannels = append(metadata.BoundChannels, channel) } - } - allMatched := make([]string, 0, len(allMatchedSet)) - for n := range allMatchedSet { - allMatched = append(allMatched, n) - } - matchedChannelsByModel, _ := model.GetBoundChannelsByModelsMap(allMatched) - - // 6) 回填每个规则模型的并集信息 - for _, idx := range ruleIndices { - mm := models[idx] - - // 端点并集 -> 序列化 - if es, ok := endpointSetByIdx[idx]; ok && mm.Endpoints == "" { - eps := make([]constant.EndpointType, 0, len(es)) - for et := range es { - eps = append(eps, et) - } - if b, err := json.Marshal(eps); err == nil { - mm.Endpoints = string(b) + sort.Slice(metadata.BoundChannels, func(i, j int) bool { + a, b := metadata.BoundChannels[i], metadata.BoundChannels[j] + if a.Name == b.Name { + return a.Type < b.Type } + return a.Name < b.Name + }) + for group := range groups { + metadata.EnableGroups = append(metadata.EnableGroups, group) } - - // 分组并集 - if gs, ok := groupSetByIdx[idx]; ok { - groups := make([]string, 0, len(gs)) - for g := range gs { - groups = append(groups, g) - } - mm.EnableGroups = groups + for endpoint := range endpoints { + metadata.SupportedEndpoints = append(metadata.SupportedEndpoints, endpoint) } - - // 配额类型集合(保持去重并排序) - if qs, ok := quotaSetByIdx[idx]; ok { - arr := make([]int, 0, len(qs)) - for k := range qs { - arr = append(arr, k) - } - sort.Ints(arr) - mm.QuotaTypes = arr - } - - // 渠道并集 - names := matchedNamesByIdx[idx] - channelSet := make(map[string]model.BoundChannel) - for _, n := range names { - for _, ch := range matchedChannelsByModel[n] { - key := ch.Name + "_" + strconv.Itoa(ch.Type) - channelSet[key] = ch - } + for quota := range quotas { + metadata.QuotaTypes = append(metadata.QuotaTypes, quota) } - if len(channelSet) > 0 { - chs := make([]model.BoundChannel, 0, len(channelSet)) - for _, ch := range channelSet { - chs = append(chs, ch) + sort.Strings(metadata.EnableGroups) + sort.Strings(metadata.SupportedEndpoints) + sort.Ints(metadata.QuotaTypes) + if metadata.NameRule != model.NameRuleExact { + for name := range names { + metadata.MatchedModels = append(metadata.MatchedModels, name) } - mm.BoundChannels = chs + sort.Strings(metadata.MatchedModels) + metadata.MatchedCount = len(names) } - - // 匹配信息 - mm.MatchedModels = names - mm.MatchedCount = len(names) } + return nil } diff --git a/controller/model_pricing_config.go b/controller/model_pricing_config.go new file mode 100644 index 000000000000..7627fe9d3465 --- /dev/null +++ b/controller/model_pricing_config.go @@ -0,0 +1,43 @@ +package controller + +import ( + "errors" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +func GetModelPricingConfig(c *gin.Context) { + snapshot, err := model.GetModelPricingSnapshot(c.QueryArray("model")) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, snapshot) +} + +func UpdateModelPricingConfig(c *gin.Context) { + var request struct { + Changes []model.ModelPricingChange `json:"changes"` + } + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + if err := model.UpdateModelPricing(request.Changes); err != nil { + status := http.StatusBadRequest + if errors.Is(err, model.ErrModelPricingConflict) { + status = http.StatusConflict + } + c.JSON(status, gin.H{"success": false, "message": err.Error()}) + return + } + names := make([]string, 0, len(request.Changes)) + for _, change := range request.Changes { + names = append(names, change.ModelName) + } + recordManageAudit(c, "model.pricing.update", map[string]any{"models": names}) + common.ApiSuccess(c, gin.H{"updated_models": names}) +} diff --git a/controller/model_sync.go b/controller/model_sync.go index f254dc88ee5e..95f7ab72e824 100644 --- a/controller/model_sync.go +++ b/controller/model_sync.go @@ -2,6 +2,7 @@ package controller import ( "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "math/rand" "net" "net/http" + "sort" "strings" "sync" "time" @@ -17,7 +19,6 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" - "gorm.io/gorm" ) // 上游地址 @@ -27,10 +28,13 @@ const ( ) func normalizeLocale(locale string) (string, bool) { - l := strings.ToLower(strings.TrimSpace(locale)) - switch l { - case "en", "zh-CN", "zh-TW", "ja": - return l, true + switch strings.ToLower(strings.TrimSpace(locale)) { + case "", "zh", "zh-cn": + return "zh", true + case "en": + return "en", true + case "ja": + return "ja", true default: return "", false } @@ -79,16 +83,6 @@ var ( cacheMutex sync.RWMutex ) -type overwriteField struct { - ModelName string `json:"model_name"` - Fields []string `json:"fields"` -} - -type syncRequest struct { - Overwrite []overwriteField `json:"overwrite"` - Locale string `json:"locale"` -} - func newHTTPClient() *http.Client { timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10) dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second} @@ -132,10 +126,7 @@ func getHTTPClient() *http.Client { func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error { var lastErr error - attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3) - if attempts < 1 { - attempts = 1 - } + attempts := max(common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3), 1) baseDelay := 200 * time.Millisecond maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10) maxBytes := int64(maxMB) << 20 @@ -165,12 +156,16 @@ func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) switch resp.StatusCode { case http.StatusOK: // read body into buffer for caching and flexible decode - limited := io.LimitReader(resp.Body, maxBytes) + limited := io.LimitReader(resp.Body, maxBytes+1) buf, err := io.ReadAll(limited) if err != nil { lastErr = err return } + if int64(len(buf)) > maxBytes { + lastErr = errors.New("upstream metadata exceeds size limit") + return + } // cache body and ETag cacheMutex.Lock() if et := resp.Header.Get("ETag"); et != "" { @@ -180,10 +175,10 @@ func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) cacheMutex.Unlock() // Try decode as envelope first - if err := json.Unmarshal(buf, out); err != nil { + if err := common.Unmarshal(buf, out); err != nil { // Try decode as pure array var arr []T - if err2 := json.Unmarshal(buf, &arr); err2 != nil { + if err2 := common.Unmarshal(buf, &arr); err2 != nil { lastErr = err return } @@ -205,9 +200,9 @@ func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) lastErr = errors.New("cache miss for 304 response") return } - if err := json.Unmarshal(buf, out); err != nil { + if err := common.Unmarshal(buf, out); err != nil { var arr []T - if err2 := json.Unmarshal(buf, &arr); err2 != nil { + if err2 := common.Unmarshal(buf, &arr); err2 != nil { lastErr = err return } @@ -234,401 +229,224 @@ func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) return lastErr } -func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int { - if vendorName == "" { - return 0 - } - if id, ok := vendorIDCache[vendorName]; ok { - return id - } - var existing model.Vendor - if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil { - vendorIDCache[vendorName] = existing.Id - return existing.Id - } - uv := vendorByName[vendorName] - v := &model.Vendor{ - Name: vendorName, - Description: uv.Description, - Icon: coalesce(uv.Icon, ""), - Status: chooseStatus(uv.Status, 1), - } - if err := v.Insert(); err == nil { - *createdVendors++ - vendorIDCache[vendorName] = v.Id - return v.Id - } - vendorIDCache[vendorName] = 0 - return 0 +type metadataSyncSource struct { + Locale string `json:"locale"` + ModelsURL string `json:"models_url"` + VendorsURL string `json:"vendors_url"` + Version string `json:"version"` } -// SyncUpstreamModels 同步上游模型与供应商: -// - 默认仅创建「未配置模型」 -// - 可通过 overwrite 选择性覆盖更新本地已有模型的字段(前提:sync_official <> 0) -func SyncUpstreamModels(c *gin.Context) { - var req syncRequest - // 允许空体 - _ = c.ShouldBindJSON(&req) - // 1) 获取未配置模型列表 - missing, err := model.GetMissingModels() - if err != nil { - common.SysError("failed to get missing models: " + err.Error()) - c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取模型列表失败,请稍后重试"}) - return - } +type metadataSyncField struct { + Field string `json:"field"` + Local any `json:"local"` + Upstream any `json:"upstream"` +} - // 若既无缺失模型需要创建,也未指定覆盖更新字段,则无需请求上游数据,直接返回 - if len(missing) == 0 && len(req.Overwrite) == 0 { - modelsURL, vendorsURL := getUpstreamURLs(req.Locale) - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": gin.H{ - "created_models": 0, - "created_vendors": 0, - "updated_models": 0, - "skipped_models": []string{}, - "created_list": []string{}, - "updated_list": []string{}, - "source": gin.H{ - "locale": req.Locale, - "models_url": modelsURL, - "vendors_url": vendorsURL, - }, - }, - }) - return - } +type metadataSyncCandidate struct { + ModelName string `json:"model_name"` + Kind string `json:"kind"` + Scope string `json:"scope"` + RecordVersion string `json:"record_version"` + Fields []metadataSyncField `json:"fields"` + Upstream *model.MetadataValues `json:"upstream,omitempty"` + VendorToCreate string `json:"vendor_to_create,omitempty"` +} - // 2) 拉取上游 vendors 与 models - timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15) - ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second) +func fetchMetadataCatalog(c *gin.Context, locale string) (metadataSyncSource, map[string]model.MetadataValues, map[string]model.Vendor, error) { + resolved, valid := normalizeLocale(locale) + if !valid { + return metadataSyncSource{}, nil, nil, errors.New("unsupported metadata language") + } + modelsURL, vendorsURL := getUpstreamURLs(resolved) + source := metadataSyncSource{Locale: resolved, ModelsURL: modelsURL, VendorsURL: vendorsURL} + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15))*time.Second) defer cancel() - - modelsURL, vendorsURL := getUpstreamURLs(req.Locale) - var vendorsEnv upstreamEnvelope[upstreamVendor] var modelsEnv upstreamEnvelope[upstreamModel] - var fetchErr error + var vendorsEnv upstreamEnvelope[upstreamVendor] + var modelsErr, vendorsErr error var wg sync.WaitGroup wg.Add(2) - go func() { - defer wg.Done() - // vendor 失败不拦截 - _ = fetchJSON(ctx, vendorsURL, &vendorsEnv) - }() - go func() { - defer wg.Done() - if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil { - fetchErr = err - } - }() + go func() { defer wg.Done(); modelsErr = fetchJSON(ctx, modelsURL, &modelsEnv) }() + go func() { defer wg.Done(); vendorsErr = fetchJSON(ctx, vendorsURL, &vendorsEnv) }() wg.Wait() - if fetchErr != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": req.Locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}}) - return + if modelsErr != nil { + return source, nil, nil, fmt.Errorf("fetch models (%s, %s): %w", resolved, modelsURL, modelsErr) } - - // 建立映射 - vendorByName := make(map[string]upstreamVendor) - for _, v := range vendorsEnv.Data { - if v.Name != "" { - vendorByName[v.Name] = v - } + if vendorsErr != nil { + return source, nil, nil, fmt.Errorf("fetch vendors (%s, %s): %w", resolved, vendorsURL, vendorsErr) + } + if !modelsEnv.Success || !vendorsEnv.Success { + return source, nil, nil, errors.New("upstream metadata source reported failure") } - modelByName := make(map[string]upstreamModel) - for _, m := range modelsEnv.Data { - if m.ModelName != "" { - modelByName[m.ModelName] = m + models := make(map[string]model.MetadataValues) + vendors := make(map[string]model.Vendor) + for _, vendor := range vendorsEnv.Data { + vendor.Name = strings.TrimSpace(vendor.Name) + if vendor.Name == "" { + continue } + vendors[vendor.Name] = model.Vendor{Name: vendor.Name, Description: vendor.Description, Icon: vendor.Icon, Status: vendor.Status} } - - // 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过 - createdModels := 0 - createdVendors := 0 - updatedModels := 0 - skipped := make([]string, 0) - createdList := make([]string, 0) - updatedList := make([]string, 0) - - // 本地缓存:vendorName -> id - vendorIDCache := make(map[string]int) - - for _, name := range missing { - up, ok := modelByName[name] - if !ok { - skipped = append(skipped, name) + for _, item := range modelsEnv.Data { + if strings.TrimSpace(item.ModelName) == "" { continue } - - // 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时) - var existing model.Model - if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil { - if existing.SyncOfficial == 0 { - skipped = append(skipped, name) - continue + endpoints := "" + if len(item.Endpoints) > 0 && string(item.Endpoints) != "null" { + if err := common.Unmarshal(item.Endpoints, &endpoints); err != nil { + endpoints = string(item.Endpoints) } } - - // 确保 vendor 存在 - vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) - - // 创建模型 - mi := &model.Model{ - ModelName: name, - Description: up.Description, - Icon: up.Icon, - Tags: up.Tags, - VendorID: vendorID, - Status: chooseStatus(up.Status, 1), - NameRule: up.NameRule, + values := model.MetadataValues{Description: item.Description, Icon: item.Icon, Tags: item.Tags, Vendor: strings.TrimSpace(item.VendorName), Endpoints: endpoints, NameRule: item.NameRule, Status: item.Status} + if err := model.ValidateMetadataValues(values); err != nil { + return source, nil, nil, fmt.Errorf("model %s: %w", item.ModelName, err) } - if err := mi.Insert(); err == nil { - createdModels++ - createdList = append(createdList, name) - } else { - skipped = append(skipped, name) + if _, duplicate := models[item.ModelName]; duplicate { + return source, nil, nil, fmt.Errorf("duplicate upstream model: %s", item.ModelName) } + models[item.ModelName] = values } - - // 4) 处理可选覆盖(更新本地已有模型的差异字段) - if len(req.Overwrite) > 0 { - // vendorIDCache 已用于创建阶段,可复用 - for _, ow := range req.Overwrite { - up, ok := modelByName[ow.ModelName] - if !ok { - continue - } - var local model.Model - if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil { - continue - } - - // 跳过被禁用官方同步的模型 - if local.SyncOfficial == 0 { - continue - } - - // 映射 vendor - newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) - - // 应用字段覆盖(事务) - _ = model.DB.Transaction(func(tx *gorm.DB) error { - needUpdate := false - if containsField(ow.Fields, "description") { - local.Description = up.Description - needUpdate = true - } - if containsField(ow.Fields, "icon") { - local.Icon = up.Icon - needUpdate = true - } - if containsField(ow.Fields, "tags") { - local.Tags = up.Tags - needUpdate = true - } - if containsField(ow.Fields, "vendor") { - local.VendorID = newVendorID - needUpdate = true - } - if containsField(ow.Fields, "name_rule") { - local.NameRule = up.NameRule - needUpdate = true - } - if containsField(ow.Fields, "status") { - local.Status = chooseStatus(up.Status, local.Status) - needUpdate = true - } - if !needUpdate { - return nil - } - if err := tx.Save(&local).Error; err != nil { - return err - } - updatedModels++ - updatedList = append(updatedList, ow.ModelName) - return nil - }) - } - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": gin.H{ - "created_models": createdModels, - "created_vendors": createdVendors, - "updated_models": updatedModels, - "skipped_models": skipped, - "created_list": createdList, - "updated_list": updatedList, - "source": gin.H{ - "locale": req.Locale, - "models_url": modelsURL, - "vendors_url": vendorsURL, - }, - }, - }) -} - -func containsField(fields []string, key string) bool { - key = strings.ToLower(strings.TrimSpace(key)) - for _, f := range fields { - if strings.ToLower(strings.TrimSpace(f)) == key { - return true - } - } - return false -} - -func coalesce(a, b string) string { - if strings.TrimSpace(a) != "" { - return a - } - return b -} - -func chooseStatus(primary, fallback int) int { - if primary == 0 && fallback != 0 { - return fallback - } - if primary != 0 { - return primary + encoded, err := common.Marshal([]any{source.Locale, models, vendors}) + if err != nil { + return source, nil, nil, err } - return 1 + source.Version = fmt.Sprintf("%x", sha256.Sum256(encoded)) + return source, models, vendors, nil } -// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择) func SyncUpstreamPreview(c *gin.Context) { - // 1) 拉取上游数据 - timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15) - ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second) - defer cancel() - - locale := c.Query("locale") - modelsURL, vendorsURL := getUpstreamURLs(locale) - - var vendorsEnv upstreamEnvelope[upstreamVendor] - var modelsEnv upstreamEnvelope[upstreamModel] - var fetchErr error - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - _ = fetchJSON(ctx, vendorsURL, &vendorsEnv) - }() - go func() { - defer wg.Done() - if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil { - fetchErr = err - } - }() - wg.Wait() - if fetchErr != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}}) + source, upstream, upstreamVendors, err := fetchMetadataCatalog(c, c.Query("locale")) + if err != nil { + common.ApiError(c, err) return } - - vendorByName := make(map[string]upstreamVendor) - for _, v := range vendorsEnv.Data { - if v.Name != "" { - vendorByName[v.Name] = v - } + locals, vendors, err := model.GetMetadataSyncState(model.DB) + if err != nil { + common.ApiError(c, err) + return } - modelByName := make(map[string]upstreamModel) - upstreamNames := make([]string, 0, len(modelsEnv.Data)) - for _, m := range modelsEnv.Data { - if m.ModelName != "" { - modelByName[m.ModelName] = m - upstreamNames = append(upstreamNames, m.ModelName) - } + missing, err := model.GetMissingModels() + if err != nil { + common.ApiError(c, err) + return } - - // 2) 本地已有模型 - var locals []model.Model - if len(upstreamNames) > 0 { - _ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error + siteNames := make(map[string]bool) + allNames := make(map[string]bool) + for name := range locals { + siteNames[name] = true + allNames[name] = true } - - // 本地 vendor 名称映射 - vendorIdSet := make(map[int]struct{}) - for _, m := range locals { - if m.VendorID != 0 { - vendorIdSet[m.VendorID] = struct{}{} + for _, name := range missing { + siteNames[name] = true + allNames[name] = true + } + for name := range upstream { + allNames[name] = true + } + names := make([]string, 0, len(allNames)) + for name := range allNames { + names = append(names, name) + } + sort.Strings(names) + vendorByID := make(map[int]*model.Vendor) + for _, vendor := range vendors { + vendorByID[vendor.Id] = vendor + } + candidates := make([]metadataSyncCandidate, 0, len(names)) + for _, name := range names { + candidate := metadataSyncCandidate{ModelName: name, Scope: "catalog", Kind: "create", Fields: []metadataSyncField{}} + if siteNames[name] { + candidate.Scope = "site" } - } - vendorIDs := make([]int, 0, len(vendorIdSet)) - for id := range vendorIdSet { - vendorIDs = append(vendorIDs, id) - } - idToVendorName := make(map[int]string) - if len(vendorIDs) > 0 { - var dbVendors []model.Vendor - _ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error - for _, v := range dbVendors { - idToVendorName[v.Id] = v.Name + local := locals[name] + up, found := upstream[name] + if !found { + candidate.Kind = "missing_upstream" + candidates = append(candidates, candidate) + continue } - } - - // 3) 缺失且上游存在的模型 - missingList, _ := model.GetMissingModels() - var missing []string - for _, name := range missingList { - if _, ok := modelByName[name]; ok { - missing = append(missing, name) + candidate.Upstream = &up + var localVendor *model.Vendor + if local != nil { + localVendor = vendorByID[local.VendorID] } - } - - // 4) 计算冲突字段 - type conflictField struct { - Field string `json:"field"` - Local interface{} `json:"local"` - Upstream interface{} `json:"upstream"` - } - type conflictItem struct { - ModelName string `json:"model_name"` - Fields []conflictField `json:"fields"` - } - - var conflicts []conflictItem - for _, local := range locals { - up, ok := modelByName[local.ModelName] - if !ok { + candidate.RecordVersion = model.MetadataRecordVersion(local, localVendor, model.FindMetadataVendor(vendors, up.Vendor)) + if local != nil && local.SyncOfficial == 0 { + candidate.Kind = "blocked" + candidates = append(candidates, candidate) continue } - fields := make([]conflictField, 0, 6) - if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) { - fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description}) - } - if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) { - fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon}) + if up.Vendor != "" && model.FindMetadataVendor(vendors, up.Vendor) == nil { + if _, exists := upstreamVendors[up.Vendor]; !exists { + candidate.Kind = "missing_vendor" + candidates = append(candidates, candidate) + continue + } + candidate.VendorToCreate = up.Vendor } - if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) { - fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags}) + localValues := model.MetadataValues{} + if local != nil { + candidate.Kind = "update" + localValues = model.MetadataValues{Description: local.Description, Icon: local.Icon, Tags: local.Tags, Endpoints: local.Endpoints, NameRule: local.NameRule, Status: local.Status} + if localVendor != nil { + localValues.Vendor = localVendor.Name + } } - // vendor 对比使用名称 - localVendor := idToVendorName[local.VendorID] - if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) { - fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName}) + localRaw, _ := common.Marshal(localValues) + upRaw, _ := common.Marshal(up) + var localFields, upFields map[string]any + _ = common.Unmarshal(localRaw, &localFields) + _ = common.Unmarshal(upRaw, &upFields) + for _, field := range model.MetadataSyncFields { + if local == nil || localFields[field] != upFields[field] { + candidate.Fields = append(candidate.Fields, metadataSyncField{Field: field, Local: localFields[field], Upstream: upFields[field]}) + } } - if local.NameRule != up.NameRule { - fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule}) + if local != nil && len(candidate.Fields) == 0 { + candidate.Kind = "unchanged" } - if local.Status != chooseStatus(up.Status, local.Status) { - fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status}) + candidates = append(candidates, candidate) + } + common.ApiSuccess(c, gin.H{"source": source, "candidates": candidates}) +} + +func SyncUpstreamModels(c *gin.Context) { + var request struct { + Locale string `json:"locale"` + SourceVersion string `json:"source_version"` + Selections []model.MetadataSyncSelection `json:"selections"` + } + if err := common.DecodeJson(c.Request.Body, &request); err != nil || len(request.Selections) == 0 || request.SourceVersion == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Preview and select metadata changes before applying"}) + return + } + source, upstream, vendors, err := fetchMetadataCatalog(c, request.Locale) + if err != nil { + common.ApiError(c, err) + return + } + if source.Version != request.SourceVersion { + c.JSON(http.StatusConflict, gin.H{"success": false, "message": "Upstream metadata changed; preview again"}) + return + } + updates := make([]model.MetadataSyncUpdate, 0, len(request.Selections)) + for _, selection := range request.Selections { + values, exists := upstream[selection.ModelName] + if !exists { + c.JSON(http.StatusConflict, gin.H{"success": false, "message": "Selected upstream model is no longer available"}) + return } - if len(fields) > 0 { - conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields}) + updates = append(updates, model.MetadataSyncUpdate{MetadataSyncSelection: selection, Values: values}) + } + result, err := model.ApplyMetadataSync(updates, vendors) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, model.ErrMetadataSyncConflict) { + status = http.StatusConflict } + c.JSON(status, gin.H{"success": false, "message": err.Error()}) + return } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": gin.H{ - "missing": missing, - "conflicts": conflicts, - "source": gin.H{ - "locale": locale, - "models_url": modelsURL, - "vendors_url": vendorsURL, - }, - }, - }) + recordManageAudit(c, "model.metadata.sync", map[string]any{"created_models": result.CreatedModels, "updated_models": result.UpdatedModels, "created_vendors": result.CreatedVendors}) + common.ApiSuccess(c, result) } diff --git a/controller/oauth.go b/controller/oauth.go index 4d9725c1cc7a..d2e92e76da38 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "errors" "fmt" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -20,13 +22,19 @@ import ( const oauthAuthFlowTTL = 10 * time.Minute type oauthStateRequest struct { - Provider string `json:"provider"` - Intent string `json:"intent"` - Aff string `json:"aff,omitempty"` + Provider string `json:"provider"` + Intent string `json:"intent"` + Aff string `json:"aff,omitempty"` + Scope string `json:"scope,omitempty"` + Context json.RawMessage `json:"context,omitempty"` } type oauthFlowPayload struct { - AffiliateCode string `json:"affiliate_code,omitempty"` + AffiliateCode string `json:"affiliate_code,omitempty"` + Verification *service.OAuthVerificationFlow `json:"verification,omitempty"` + Telegram *oauth.TelegramOAuthFlow `json:"telegram,omitempty"` + SessionIdentity *service.AuthIdentity `json:"session_identity,omitempty"` + Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"` } // providerParams returns map with Provider key for i18n templates @@ -45,15 +53,26 @@ func GenerateOAuthCode(c *gin.Context) { request.Intent = strings.TrimSpace(request.Intent) request.Aff = strings.TrimSpace(request.Aff) if oauth.GetProvider(request.Provider) == nil || - (request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind) || + (request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind && request.Intent != model.AuthFlowIntentVerify) || len(request.Aff) > 32 || - (request.Intent == model.AuthFlowIntentBind && request.Aff != "") { + (request.Intent != model.AuthFlowIntentLogin && request.Aff != "") || + (request.Intent != model.AuthFlowIntentVerify && (request.Scope != "" || len(request.Context) != 0)) { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } userID := 0 sessionID := "" - if request.Intent == model.AuthFlowIntentBind { + flowPayload := oauthFlowPayload{AffiliateCode: request.Aff} + bindingStarted := false + if request.Provider == "telegram" { + telegramFlow, err := oauth.NewTelegramOAuthFlow() + if err != nil { + writeSecurityOperationError(c, err) + return + } + flowPayload.Telegram = telegramFlow + } + if request.Intent == model.AuthFlowIntentBind || request.Intent == model.AuthFlowIntentVerify { identity, ok := middleware.GetSessionAuthIdentity(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"}) @@ -61,10 +80,40 @@ func GenerateOAuthCode(c *gin.Context) { } userID = identity.UserID sessionID = identity.SessionID + if request.Intent == model.AuthFlowIntentBind { + defer func() { + recordUserSecurityAudit(c, userID, "user.binding_start", map[string]any{"provider": request.Provider, "success": bindingStarted}) + }() + context, err := common.Marshal(service.AccountBindingContext{Provider: request.Provider}) + if err != nil { + writeSecurityOperationError(c, err) + return + } + flowPayload.Authorization = middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}) + if flowPayload.Authorization == nil { + return + } + flowPayload.SessionIdentity = &identity + } + if flowPayload.Telegram != nil { + if _, _, err := service.ValidateLoginSession(identity); err != nil { + writeSecurityOperationError(c, err) + return + } + flowPayload.SessionIdentity = &identity + } + if request.Intent == model.AuthFlowIntentVerify { + verification, err := service.StartOAuthVerification(identity, service.VerificationOperation{Scope: request.Scope, Context: request.Context}, request.Provider) + if err != nil { + writeSecurityOperationError(c, err) + return + } + flowPayload.Verification = verification + } } - payload, err := common.Marshal(oauthFlowPayload{AffiliateCode: request.Aff}) + payload, err := common.Marshal(flowPayload) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } expiresAt := time.Now().Add(oauthAuthFlowTTL) @@ -78,16 +127,18 @@ func GenerateOAuthCode(c *gin.Context) { ExpiresAt: expiresAt, }) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } + bindingStarted = request.Intent == model.AuthFlowIntentBind + data := gin.H{"flow_token": state, "expires_at": expiresAt.Unix()} + if flowPayload.Telegram != nil { + data["authorization_url"] = flowPayload.Telegram.AuthorizationURL(state) + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": gin.H{ - "flow_token": state, - "expires_at": expiresAt.Unix(), - }, + "data": data, }) } @@ -122,8 +173,14 @@ func HandleOAuth(c *gin.Context) { Provider: providerName, Intent: pendingFlow.Intent, } - // 2. Bind flows are bound to the live dashboard Session that created them. + bindSucceeded, notificationFailed := false, false if pendingFlow.Intent == model.AuthFlowIntentBind { + defer func() { + recordUserSecurityAudit(c, pendingFlow.UserId, "user.binding_bind", map[string]any{"provider": providerName, "success": bindSucceeded, "notification_failed": notificationFailed}) + }() + } + // Bind and verification callbacks must use the dashboard session that started them. + if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify { identity, ok := middleware.GetSessionAuthIdentity(c) if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId { c.JSON(http.StatusForbidden, gin.H{ @@ -134,12 +191,51 @@ func HandleOAuth(c *gin.Context) { } consumeMatch.UserId = identity.UserID consumeMatch.SessionId = identity.SessionID + if pendingFlow.Intent == model.AuthFlowIntentBind { + var payload oauthFlowPayload + if err := common.UnmarshalJsonStr(pendingFlow.Payload, &payload); err != nil { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) + return + } + context, err := common.Marshal(service.AccountBindingContext{Provider: providerName}) + if err != nil { + writeSecurityOperationError(c, err) + return + } + if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil { + writeSecurityOperationError(c, err) + return + } + } } else if pendingFlow.Intent != model.AuthFlowIntentLogin { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } // 3. Check if provider is enabled + var telegramPayload oauthFlowPayload + if providerName == "telegram" { + if err := oauth.TelegramConfigurationError(); err != nil { + writeSecurityOperationError(c, err) + return + } + if err := common.UnmarshalJsonStr(pendingFlow.Payload, &telegramPayload); err != nil || telegramPayload.Telegram == nil { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) + return + } + if pendingFlow.Intent != model.AuthFlowIntentLogin { + identity, _ := middleware.GetSessionAuthIdentity(c) + if telegramPayload.SessionIdentity == nil || *telegramPayload.SessionIdentity != identity { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) + return + } + if _, _, err := service.ValidateLoginSession(identity); err != nil { + writeSecurityOperationError(c, err) + return + } + } + c.Set(oauth.TelegramOAuthFlowContextKey, telegramPayload.Telegram) + } if !provider.IsEnabled() { common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName())) return @@ -162,15 +258,14 @@ func HandleOAuth(c *gin.Context) { }) return } - if pendingFlow.Intent == model.AuthFlowIntentBind { - handleOAuthBind(c, provider, pendingFlow, state) - return - } - // 5. Exchange code for token code := c.Query("code") token, err := provider.ExchangeToken(c.Request.Context(), code, c) if err != nil { + if providerName == "telegram" { + writeSecurityOperationError(c, err) + return + } handleOAuthError(c, err) return } @@ -178,19 +273,52 @@ func HandleOAuth(c *gin.Context) { // 6. Get user info oauthUser, err := provider.GetUserInfo(c.Request.Context(), token) if err != nil { + if providerName == "telegram" { + writeSecurityOperationError(c, err) + return + } handleOAuthError(c, err) return } + if pendingFlow.Intent == model.AuthFlowIntentBind { + bindSucceeded, notificationFailed = handleOAuthBind(c, providerName, provider, oauthUser, pendingFlow, state, consumeMatch) + return + } flow, err := model.ConsumeAuthFlow(state, consumeMatch) if err != nil { c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) return } + switch flow.Intent { + case model.AuthFlowIntentLogin: + handleOAuthLogin(c, provider, oauthUser, flow) + case model.AuthFlowIntentVerify: + handleOAuthVerification(c, providerName, oauthUser, flow) + } +} + +func handleOAuthVerification(c *gin.Context, provider string, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) { + var payload oauthFlowPayload + if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil { + writeSecurityOperationError(c, err) + return + } + identity, _ := middleware.GetSessionAuthIdentity(c) + proof, err := service.FinishOAuthVerification(identity, provider, oauthUser.ProviderUserID, payload.Verification) + if err != nil { + writeSecurityOperationError(c, err) + return + } + recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope, "provider": provider}) + common.ApiSuccess(c, proof) +} + +func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) { // 7. Find or create user var payload oauthFlowPayload if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode) @@ -207,7 +335,7 @@ func HandleOAuth(c *gin.Context) { case *OAuthEmailAlreadyTakenError: common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) default: - common.ApiError(c, err) + writeSecurityOperationError(c, err) } return } @@ -223,74 +351,69 @@ func HandleOAuth(c *gin.Context) { } // handleOAuthBind handles binding OAuth account to existing user -func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model.AuthFlow, flowToken string) { - // Exchange code for token - code := c.Query("code") - token, err := provider.ExchangeToken(c.Request.Context(), code, c) - if err != nil { - handleOAuthError(c, err) - return +func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow, state string, match model.AuthFlowMatch) (bool, bool) { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + writeSecurityOperationError(c, service.ErrAuthTokenInvalid) + return false, false } - - // Get user info - oauthUser, err := provider.GetUserInfo(c.Request.Context(), token) + var payload oauthFlowPayload + if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) + return false, false + } + context, err := common.Marshal(service.AccountBindingContext{Provider: providerName}) if err != nil { - handleOAuthError(c, err) - return + writeSecurityOperationError(c, err) + return false, false + } + // Recheck after the external provider round trip, then validate the session + // under the transaction's locks before consuming the flow and writing. + if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil { + writeSecurityOperationError(c, err) + return false, false } - - // Check if this OAuth account is already bound (check both new ID and legacy ID) if provider.IsUserIDTaken(oauthUser.ProviderUserID) { common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName())) - return - } - // Also check legacy ID to prevent duplicate bindings during migration period - if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" { - if provider.IsUserIDTaken(legacyID) { - common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName())) - return - } + return false, false } - - if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposeOAuth, - Provider: pendingFlow.Provider, - Intent: model.AuthFlowIntentBind, - UserId: pendingFlow.UserId, - SessionId: pendingFlow.SessionId, - }); err != nil { - c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) - return + if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" && provider.IsUserIDTaken(legacyID) { + common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName())) + return false, false } - - userId := pendingFlow.UserId - - // Handle binding based on provider type - if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok { - // Custom provider: use user_oauth_bindings table - err = model.UpdateUserOAuthBinding(userId, genericProvider.GetProviderId(), oauthUser.ProviderUserID) - if err != nil { - common.ApiError(c, err) - return + _, err = model.ConsumeAuthFlowWithAction(state, match, func(tx *gorm.DB, _ *model.AuthFlow) error { + if providerName == "telegram" { + return model.BindTelegramForSessionWithTx(tx, identity, oauthUser.ProviderUserID) } - } else { - // Built-in provider: 只更新绑定列。完整快照的 user.Update 会把读取时刻的 - // role/status/group 一并写回,覆盖并发发生的封禁、降权或分组变更。 - err = model.UpdateUserBindColumn(userId, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID) - if err != nil { - common.ApiError(c, err) - return + if custom, ok := provider.(*oauth.GenericOAuthProvider); ok { + return model.UpdateUserOAuthBindingForSessionWithTx(tx, identity, custom.GetProviderId(), oauthUser.ProviderUserID) } - } - - common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{ - "action": "bind", + return model.UpdateUserBindColumnForSessionWithTx(tx, identity, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID) }) + if err != nil { + writeSecurityOperationError(c, err) + return false, false + } + user, err := model.GetUserById(identity.UserID, false) + if err != nil { + writeSecurityOperationError(c, err) + return true, true + } + notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil + common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind", "notification_warning": notificationFailed}) + return true, notificationFailed } // findOrCreateOAuthUser finds existing user or creates new user func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, affiliateCode string) (*model.User, error) { user := &model.User{} + if provider.ProviderUserIDColumn() == "telegram_id" { + err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, oauth.ErrTelegramAccountNotBound + } + return user, err + } // Check if user already exists with new ID if provider.IsUserIDTaken(oauthUser.ProviderUserID) { @@ -404,7 +527,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o // Set the provider user ID on the user model and update provider.SetProviderUserID(user, oauthUser.ProviderUserID) - if err := tx.Model(user).Updates(map[string]interface{}{ + if err := tx.Model(user).Updates(map[string]any{ "github_id": user.GitHubId, "discord_id": user.DiscordId, "oidc_id": user.OidcId, @@ -461,6 +584,6 @@ func handleOAuthError(c *gin.Context, err error) { case *oauth.TrustLevelError: common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow) default: - common.ApiError(c, err) + writeSecurityOperationError(c, err) } } diff --git a/controller/option.go b/controller/option.go index 1feb4a818d37..1121b9b435e7 100644 --- a/controller/option.go +++ b/controller/option.go @@ -3,6 +3,7 @@ package controller import ( "fmt" "net/http" + "slices" "sort" "strconv" "strings" @@ -85,7 +86,7 @@ func GetOptions(c *gin.Context) { optionValues := make(map[string]string) common.OptionMapRWMutex.Lock() for k, v := range common.OptionMap { - if k == "theme.frontend" { + if k == "theme.frontend" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" { continue } value := common.Interface2String(v) @@ -101,14 +102,24 @@ func GetOptions(c *gin.Context) { Key: k, Value: value, }) - for _, optionKey := range completionRatioMetaOptionKeys { - if optionKey == k { - optionValues[k] = value - break - } + if slices.Contains(completionRatioMetaOptionKeys, k) { + optionValues[k] = value } } common.OptionMapRWMutex.Unlock() + // Display the same effective expressions used by pricing and settlement, + // including built-in defaults absent from persisted administrator options. + for key, values := range map[string]map[string]string{ + "billing_setting.billing_mode": billing_setting.GetBillingModeCopy(), + "billing_setting.billing_expr": billing_setting.GetBillingExprCopy(), + } { + encoded, err := common.Marshal(values) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + options = append(options, &model.Option{Key: key, Value: string(encoded)}) + } options = append(options, &model.Option{ Key: "CompletionRatioMeta", Value: buildCompletionRatioMetaValue(optionValues), @@ -222,10 +233,11 @@ func UpdateOption(c *gin.Context) { return } case "TelegramOAuthEnabled": - if option.Value == "true" && common.TelegramBotToken == "" { + if option.Value == "true" && !system_setting.GetTelegramSettings().IsConfigured() { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无法启用 Telegram OAuth,请先填入 Telegram Bot Token!", + "code": "TELEGRAM_OAUTH_NOT_CONFIGURED", + "message": "Telegram OAuth is not configured or enabled. Please contact your administrator.", }) return } @@ -409,7 +421,7 @@ func UpdateOption(c *gin.Context) { return } // 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。 - recordManageAudit(c, "option.update", map[string]interface{}{ + recordManageAudit(c, "option.update", map[string]any{ "key": option.Key, }) c.JSON(http.StatusOK, gin.H{ diff --git a/controller/passkey.go b/controller/passkey.go index 198df804b838..94fc10665aef 100644 --- a/controller/passkey.go +++ b/controller/passkey.go @@ -20,19 +20,14 @@ import ( webauthnlib "github.com/go-webauthn/webauthn/webauthn" ) -const ( - securityProofScopeChannelKeyRead = "channel.key.read" - securityProofScopePasskeyRegister = "passkey.register" - securityProofScopePasskeyDelete = "passkey.delete" -) - type passkeyFinishRequest struct { FlowToken string `json:"flow_token"` Credential json.RawMessage `json:"credential"` } type passkeyVerifyBeginRequest struct { - Scope string `json:"scope"` + Scope string `json:"scope"` + Context json.RawMessage `json:"context,omitempty"` } func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) { @@ -57,20 +52,18 @@ func PasskeyRegisterBegin(c *gin.Context) { user, err := getAuthenticatedUser(c) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": err.Error(), - }) + writeSecurityOperationError(c, err) return } - if !requirePasskeyRegistrationVerification(c, user.Id) { + authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}) + if authorization == nil { return } credential, err := model.GetPasskeyByUserID(user.Id) if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } if errors.Is(err, model.ErrPasskeyNotFound) { @@ -79,12 +72,14 @@ func PasskeyRegisterBegin(c *gin.Context) { wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } waUser := passkeysvc.NewWebAuthnUser(user, credential) - var options []webauthnlib.RegistrationOption + selection := wa.Config.AuthenticatorSelection + selection.UserVerification = protocol.VerificationRequired + options := []webauthnlib.RegistrationOption{webauthnlib.WithAuthenticatorSelection(selection)} if credential != nil { descriptor := credential.ToWebAuthnCredential().Descriptor() options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor})) @@ -92,24 +87,22 @@ func PasskeyRegisterBegin(c *gin.Context) { creation, sessionData, err := wa.BeginRegistration(waUser, options...) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } identity, ok := middleware.GetSessionAuthIdentity(c) if !ok { - common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + common.ApiErrorMsg(c, "当前认证方式不支持安全验证") return } flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow( model.AuthFlowPurposePasskeyRegister, - user.Id, - identity.SessionID, - securityProofScopePasskeyRegister, + passkeysvc.FlowSecurity{AuthSessionIdentity: identity, Scope: authorization.Scope, ContextHash: authorization.ContextHash, Authorization: authorization}, sessionData, ) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -135,36 +128,29 @@ func PasskeyRegisterFinish(c *gin.Context) { user, err := getAuthenticatedUser(c) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - if !requirePasskeyRegistrationVerification(c, user.Id) { + writeSecurityOperationError(c, err) return } - request, err := parsePasskeyFinishRequest(c) if err != nil { - common.ApiError(c, err) + common.ApiErrorMsg(c, "无效的 Passkey 验证请求") return } parsedCredential, err := protocol.ParseCredentialCreationResponseBytes(request.Credential) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } credentialRecord, err := model.GetPasskeyByUserID(user.Id) if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } if errors.Is(err, model.ErrPasskeyNotFound) { @@ -173,24 +159,31 @@ func PasskeyRegisterFinish(c *gin.Context) { identity, ok := middleware.GetSessionAuthIdentity(c) if !ok { - common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + common.ApiErrorMsg(c, "当前认证方式不支持安全验证") return } - sessionData, _, err := passkeysvc.PopSessionDataFlow( + sessionData, security, err := passkeysvc.PopSessionDataFlow( request.FlowToken, model.AuthFlowPurposePasskeyRegister, - user.Id, - identity.SessionID, + identity, ) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) + return + } + if sessionData.UserVerification != protocol.VerificationRequired { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) + return + } + if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}, security.Authorization); err != nil { + writeSecurityOperationError(c, err) return } waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord) credential, err := wa.CreateCredential(waUser, *sessionData, parsedCredential) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -200,13 +193,13 @@ func PasskeyRegisterFinish(c *gin.Context) { return } - if err := model.UpsertPasskeyCredentialWithAuthVersion(passkeyCredential); err != nil { - common.ApiError(c, err) + if err := model.RegisterPasskeyForSession(identity, passkeyCredential); err != nil { + writeSecurityOperationError(c, err) return } bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_registered") if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -221,29 +214,26 @@ func PasskeyRegisterFinish(c *gin.Context) { func PasskeyDelete(c *gin.Context) { user, err := getAuthenticatedUser(c) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": err.Error(), - }) + writeSecurityOperationError(c, err) return } - if !requirePasskeyDeleteVerification(c, user.Id) { + if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopePasskeyDelete}) == nil { return } identity, ok := middleware.GetSessionAuthIdentity(c) if !ok { - common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + common.ApiErrorMsg(c, "当前认证方式不支持安全验证") return } - if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil { - common.ApiError(c, err) + if err := model.DeletePasskeyForSession(identity); err != nil { + writeSecurityOperationError(c, err) return } bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_deleted") if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -258,10 +248,7 @@ func PasskeyDelete(c *gin.Context) { func PasskeyStatus(c *gin.Context) { user, err := getAuthenticatedUser(c) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": err.Error(), - }) + writeSecurityOperationError(c, err) return } @@ -277,7 +264,7 @@ func PasskeyStatus(c *gin.Context) { return } if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -304,25 +291,23 @@ func PasskeyLoginBegin(c *gin.Context) { wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } - assertion, sessionData, err := wa.BeginDiscoverableLogin() + assertion, sessionData, err := wa.BeginDiscoverableLogin(webauthnlib.WithUserVerification(protocol.VerificationRequired)) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow( model.AuthFlowPurposePasskeyLogin, - 0, - "", - "", + passkeysvc.FlowSecurity{}, sessionData, ) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -348,29 +333,32 @@ func PasskeyLoginFinish(c *gin.Context) { request, err := parsePasskeyFinishRequest(c) if err != nil { - common.ApiError(c, err) + common.ApiErrorMsg(c, "无效的 Passkey 验证请求") return } parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } sessionData, _, err := passkeysvc.PopSessionDataFlow( request.FlowToken, model.AuthFlowPurposePasskeyLogin, - 0, - "", + service.AuthIdentity{}, ) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) + return + } + if sessionData.UserVerification != protocol.VerificationRequired { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) return } @@ -388,7 +376,7 @@ func PasskeyLoginFinish(c *gin.Context) { } if user.Status != common.UserStatusEnabled { - return nil, errors.New("该用户已被禁用") + return nil, model.ErrUserSessionInactive } if len(userHandle) > 0 { @@ -406,7 +394,7 @@ func PasskeyLoginFinish(c *gin.Context) { waUser, credential, err := wa.ValidatePasskeyLogin(handler, *sessionData, parsedCredential) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -428,11 +416,12 @@ func PasskeyLoginFinish(c *gin.Context) { } if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now()); err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } - setupLogin(modelUser, c) + c.Set("login_verification_method", service.VerificationMethodPasskey) + setupLoginAtAuthVersion(modelUser, modelUser.AuthVersion, c) } func AdminResetPasskey(c *gin.Context) { @@ -444,7 +433,7 @@ func AdminResetPasskey(c *gin.Context) { user := &model.User{Id: id} if err := user.FillUserById(); err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } myRole := c.GetInt("role") @@ -461,20 +450,20 @@ func AdminResetPasskey(c *gin.Context) { }) return } - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } if _, err := model.RevokeAllUserSessions(user.Id, "admin_passkey_reset"); err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } - recordManageAuditFor(c, user.Id, "user.reset_passkey", map[string]interface{}{ + recordManageAuditFor(c, user.Id, "user.reset_passkey", map[string]any{ "username": user.Username, "id": user.Id, }) @@ -495,19 +484,26 @@ func PasskeyVerifyBegin(c *gin.Context) { user, err := getAuthenticatedUser(c) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": err.Error(), - }) + writeSecurityOperationError(c, err) return } var request passkeyVerifyBeginRequest if err := common.DecodeJson(c.Request.Body, &request); err != nil { - common.ApiError(c, errors.New("无效的 Passkey 验证请求")) + common.ApiErrorMsg(c, "无效的 Passkey 验证请求") + return + } + binding, err := service.BindVerificationOperation(service.VerificationOperation{Scope: request.Scope, Context: request.Context}) + if err != nil { + writeSecurityOperationError(c, err) + return + } + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + writeSecurityOperationError(c, service.ErrAuthTokenInvalid) return } - if !isAllowedSecurityProofScope(request.Scope) { - common.ApiError(c, errors.New("不支持的安全验证范围")) + if _, err := service.RequireVerificationMethod(identity, request.Scope, service.VerificationMethodPasskey); err != nil { + writeSecurityOperationError(c, err) return } @@ -522,31 +518,24 @@ func PasskeyVerifyBegin(c *gin.Context) { wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } waUser := passkeysvc.NewWebAuthnUser(user, credential) - assertion, sessionData, err := wa.BeginLogin(waUser) + assertion, sessionData, err := wa.BeginLogin(waUser, webauthnlib.WithUserVerification(protocol.VerificationRequired)) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } - identity, ok := middleware.GetSessionAuthIdentity(c) - if !ok { - common.ApiError(c, errors.New("当前认证方式不支持安全验证")) - return - } flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow( model.AuthFlowPurposePasskeyStepUp, - user.Id, - identity.SessionID, - request.Scope, + passkeysvc.FlowSecurity{AuthSessionIdentity: identity, Scope: binding.Scope, ContextHash: binding.ContextHash}, sessionData, ) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -572,27 +561,24 @@ func PasskeyVerifyFinish(c *gin.Context) { user, err := getAuthenticatedUser(c) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": err.Error(), - }) + writeSecurityOperationError(c, err) return } request, err := parsePasskeyFinishRequest(c) if err != nil { - common.ApiError(c, err) + common.ApiErrorMsg(c, "无效的 Passkey 验证请求") return } parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } wa, err := passkeysvc.BuildWebAuthn(c.Request) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } @@ -607,99 +593,56 @@ func PasskeyVerifyFinish(c *gin.Context) { identity, ok := middleware.GetSessionAuthIdentity(c) if !ok { - common.ApiError(c, errors.New("当前认证方式不支持安全验证")) + common.ApiErrorMsg(c, "当前认证方式不支持安全验证") return } - sessionData, scope, err := passkeysvc.PopSessionDataFlow( + sessionData, security, err := passkeysvc.PopSessionDataFlow( request.FlowToken, model.AuthFlowPurposePasskeyStepUp, - user.Id, - identity.SessionID, + identity, ) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) + return + } + if sessionData.UserVerification != protocol.VerificationRequired { + writeSecurityOperationError(c, model.ErrAuthFlowInvalid) return } waUser := passkeysvc.NewWebAuthnUser(user, credential) validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now()); err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } - proofToken, proofExpiresAt, err := service.IssueSecurityProof(identity, secureVerificationMethodPasskey, []string{scope}) + proof, err := service.CompleteSecurityVerification(identity, service.VerificationBinding{Scope: security.Scope, ContextHash: security.ContextHash}, service.VerificationMethodPasskey) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Passkey 验证成功", - "data": gin.H{ - "proof_token": proofToken, - "expires_at": proofExpiresAt, - "method": secureVerificationMethodPasskey, - "scope": scope, - }, - }) + recordUserSecurityAudit(c, user.Id, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope}) + common.ApiSuccess(c, proof) } func getAuthenticatedUser(c *gin.Context) (*model.User, error) { id := c.GetInt("id") if id == 0 { - return nil, errors.New("未登录") + return nil, service.ErrAuthTokenInvalid } - user := &model.User{Id: id} - if err := user.FillUserById(); err != nil { + user, err := model.GetUserById(id, false) + if err != nil { return nil, err } if user.Status != common.UserStatusEnabled { - return nil, errors.New("该用户已被禁用") + return nil, model.ErrUserSessionInactive } return user, nil } - -func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool { - twoFA, err := model.GetTwoFAByUserId(userID) - if err != nil { - common.ApiError(c, err) - return false - } - if twoFA == nil || !twoFA.IsEnabled { - return true - } - return middleware.RequireSecurityProof(c, securityProofScopePasskeyRegister, []string{secureVerificationMethod2FA}) -} - -func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool { - twoFA, err := model.GetTwoFAByUserId(userID) - if err != nil { - common.ApiError(c, err) - return false - } - if twoFA != nil && twoFA.IsEnabled { - return middleware.RequireSecurityProof(c, securityProofScopePasskeyDelete, []string{secureVerificationMethod2FA}) - } - - _, err = model.GetPasskeyByUserID(userID) - if err != nil { - if errors.Is(err, model.ErrPasskeyNotFound) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "该用户尚未绑定 Passkey", - }) - return false - } - common.ApiError(c, err) - return false - } - - return middleware.RequireSecurityProof(c, securityProofScopePasskeyDelete, []string{secureVerificationMethodPasskey}) -} diff --git a/controller/passkey_test.go b/controller/passkey_test.go index 84cdbf4ecb0f..f1150f7ec3a6 100644 --- a/controller/passkey_test.go +++ b/controller/passkey_test.go @@ -1,7 +1,9 @@ package controller import ( - "fmt" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "net/http" "net/http/httptest" "strings" @@ -13,10 +15,8 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/gorm" ) type passkeyTestBody struct { @@ -43,88 +43,30 @@ func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) { assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength) } -func TestPasskeyRegisterFinishRejectsMissingOrWrongProofWithoutConsumingFlow(t *testing.T) { - previousDB := model.DB - previousType := common.MainDatabaseType() - previousRedis := common.RedisEnabled - previousSecret := common.SessionSecret - settings := system_setting.GetPasskeySettings() - previousSettings := *settings - dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) +func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + system_setting.GetPasskeySettings().UserVerification = "required" + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.User{}, &model.TwoFA{}, &model.AuthFlow{})) - model.DB = db - common.SetMainDatabaseType(common.DatabaseTypeSQLite) - common.RedisEnabled = false - common.SessionSecret = "passkey-register-proof-test-secret" - *settings = system_setting.PasskeySettings{Enabled: true} - t.Cleanup(func() { - model.DB = previousDB - common.SetMainDatabaseType(previousType) - common.RedisEnabled = previousRedis - common.SessionSecret = previousSecret - *settings = previousSettings - sqlDB, dbErr := db.DB() - if dbErr == nil { - _ = sqlDB.Close() - } + payload, err := common.Marshal(map[string]any{"scope": service.VerificationScopePasskeyRegister}) + require.NoError(t, err) + token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ + Purpose: model.AuthFlowPurposePasskeyRegister, UserId: identity.UserID, SessionId: identity.SessionID, + Payload: string(payload), ExpiresAt: time.Now().Add(time.Minute), }) - - user := &model.User{ - Username: "passkey-proof-user", Password: "password-placeholder", Role: common.RoleCommonUser, - Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, - } - require.NoError(t, db.Create(user).Error) - require.NoError(t, db.Create(&model.TwoFA{UserId: user.Id, Secret: "totp-secret", IsEnabled: true}).Error) - identity := service.AuthIdentity{ - UserID: user.Id, SessionID: "passkey-proof-session", UserAuthVersion: 1, SessionVersion: 1, - } - wrongScopeProof, _, err := service.IssueSecurityProof(identity, secureVerificationMethod2FA, []string{securityProofScopePasskeyDelete}) require.NoError(t, err) - - tests := []struct { - name string - proof string - expectedCode string - }{ - {name: "missing proof", expectedCode: "SECURITY_PROOF_REQUIRED"}, - {name: "wrong scope proof", proof: wrongScopeProof, expectedCode: "SECURITY_PROOF_SCOPE_MISMATCH"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ - Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID, - Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute), - }) - require.NoError(t, err) - body := fmt.Sprintf(`{"flow_token":%q,"credential":{}}`, flowToken) - request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", strings.NewReader(body)) - request.Header.Set("Content-Type", "application/json") - if test.proof != "" { - request.Header.Set("X-Security-Proof", test.proof) - } - response := httptest.NewRecorder() - context, _ := gin.CreateTestContext(response) - context.Request = request - context.Set("id", identity.UserID) - context.Set("session_id", identity.SessionID) - context.Set("auth_version", identity.UserAuthVersion) - context.Set("session_version", identity.SessionVersion) - - PasskeyRegisterFinish(context) - - assert.Equal(t, http.StatusForbidden, response.Code) - var responseBody struct { - Code string `json:"code"` - } - require.NoError(t, common.Unmarshal(response.Body.Bytes(), &responseBody)) - assert.Equal(t, test.expectedCode, responseBody.Code) - flow, err := model.GetAuthFlow(flowToken, model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID, - }) - require.NoError(t, err) - assert.Nil(t, flow.ConsumedAt) - }) - } + body, err := common.Marshal(passkeyFinishRequest{ + FlowToken: token, Credential: securityPasskeyResponse(t, key, "test-challenge", true, 0), + }) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.False(t, result.Success) + assert.Equal(t, "AUTH_FLOW_INVALID", result.Code) + _, err = model.GetPasskeyByUserID(identity.UserID) + assert.ErrorIs(t, err, model.ErrPasskeyNotFound) + flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyRegister}) + require.NoError(t, err) + assert.Nil(t, flow.ConsumedAt) } diff --git a/controller/pricing.go b/controller/pricing.go index 8252327244c4..ce3939c22421 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -1,6 +1,8 @@ package controller import ( + "maps" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" @@ -38,9 +40,7 @@ func GetPricing(c *gin.Context) { userId, exists := c.Get("id") usableGroup := map[string]string{} groupRatio := map[string]float64{} - for s, f := range ratio_setting.GetGroupRatioCopy() { - groupRatio[s] = f - } + maps.Copy(groupRatio, ratio_setting.GetGroupRatioCopy()) var group string if exists { user, err := model.GetUserCache(userId.(int)) diff --git a/controller/ratio_config.go b/controller/ratio_config.go index b9b9d479a116..163dc2f90fb0 100644 --- a/controller/ratio_config.go +++ b/controller/ratio_config.go @@ -3,6 +3,7 @@ package controller import ( "net/http" + "github.com/QuantumNous/new-api/setting/billing_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" @@ -20,6 +21,6 @@ func GetRatioConfig(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": ratio_setting.GetExposedData(), + "data": billing_setting.GetPricingSyncData(map[string]any(ratio_setting.GetExposedData())), }) } diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index 0001a60be7a1..a2cc6303a65d 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -52,7 +52,7 @@ func nearlyEqual(a, b float64) bool { return b-a < floatEpsilon } -func valuesEqual(a, b interface{}) bool { +func valuesEqual(a, b any) bool { af, aok := a.(float64) bf, bok := b.(float64) if aok && bok { @@ -139,6 +139,75 @@ func getLocalPricingSyncData() map[string]any { return data } +// effectivePricingSyncData follows the billing engine's mode precedence. An +// inactive expression and numeric settings covered by an active expression +// are not separate prices and must not appear as synchronization differences. +func effectivePricingSyncData(data map[string]any) map[string]any { + result := make(map[string]any, len(pricingSyncFields)) + names := make(map[string]struct{}) + for _, field := range pricingSyncFields { + entries := make(map[string]any) + for name, raw := range valueMap(data[field]) { + value := normalizeSyncValue(field, raw) + if numericPricingSyncFields[field] { + number, ok := value.(float64) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 { + continue + } + } + entries[name] = value + names[name] = struct{}{} + } + result[field] = entries + } + modes := valueMap(result[billing_setting.BillingModeField]) + expressions := valueMap(result[billing_setting.BillingExprField]) + for name := range names { + expression, _ := expressions[name].(string) + if modes[name] == billing_setting.BillingModeTieredExpr { + if strings.TrimSpace(expression) == "" { + for _, field := range pricingSyncFields { + delete(valueMap(result[field]), name) + } + continue + } + expressions[name] = strings.TrimSpace(expression) + for field := range numericPricingSyncFields { + delete(valueMap(result[field]), name) + } + continue + } + delete(expressions, name) + modes[name] = billing_setting.BillingModeRatio + _, fixed := valueMap(result["model_price"])[name] + _, token := valueMap(result["model_ratio"])[name] + if !fixed && !token { + for _, field := range pricingSyncFields { + delete(valueMap(result[field]), name) + } + continue + } + if fixed { + for field := range numericPricingSyncFields { + if field != "model_price" { + delete(valueMap(result[field]), name) + } + } + } + } + return result +} + +func modelPricingSyncValues(data map[string]any, name string) map[string]any { + values := make(map[string]any) + for _, field := range pricingSyncFields { + if value, exists := valueMap(data[field])[name]; exists { + values[field] = value + } + } + return values +} + func FetchUpstreamRatios(c *gin.Context) { var req dto.UpstreamRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -283,7 +352,7 @@ func FetchUpstreamRatios(c *gin.Context) { // 简单重试:最多 3 次,指数退避 var resp *http.Response var lastErr error - for attempt := 0; attempt < 3; attempt++ { + for attempt := range 3 { resp, lastErr = client.Do(httpReq) if lastErr == nil { break @@ -381,9 +450,9 @@ func FetchUpstreamRatios(c *gin.Context) { var pricingItems []struct { ModelName string `json:"model_name"` QuotaType int `json:"quota_type"` - ModelRatio float64 `json:"model_ratio"` - ModelPrice float64 `json:"model_price"` - CompletionRatio float64 `json:"completion_ratio"` + ModelRatio *float64 `json:"model_ratio"` + ModelPrice *float64 `json:"model_price"` + CompletionRatio *float64 `json:"completion_ratio"` CacheRatio *float64 `json:"cache_ratio"` CreateCacheRatio *float64 `json:"create_cache_ratio"` ImageRatio *float64 `json:"image_ratio"` @@ -413,16 +482,22 @@ func FetchUpstreamRatios(c *gin.Context) { if item.ModelName == "" { continue } - if item.BillingMode == billing_setting.BillingModeTieredExpr && strings.TrimSpace(item.BillingExpr) != "" { + if item.BillingMode == billing_setting.BillingModeTieredExpr { billingModeMap[item.ModelName] = billing_setting.BillingModeTieredExpr billingExprMap[item.ModelName] = item.BillingExpr + continue } if item.QuotaType == 1 { - modelPriceMap[item.ModelName] = item.ModelPrice + if item.ModelPrice != nil { + modelPriceMap[item.ModelName] = *item.ModelPrice + } } else { - modelRatioMap[item.ModelName] = item.ModelRatio - // completionRatio 可能为 0,此时也直接赋值,保持与上游一致 - completionRatioMap[item.ModelName] = item.CompletionRatio + if item.ModelRatio != nil { + modelRatioMap[item.ModelName] = *item.ModelRatio + } + if item.CompletionRatio != nil { + completionRatioMap[item.ModelName] = *item.CompletionRatio + } } if item.CacheRatio != nil { cacheRatioMap[item.ModelName] = *item.CacheRatio @@ -495,7 +570,7 @@ func FetchUpstreamRatios(c *gin.Context) { wg.Wait() close(ch) - localData := getLocalPricingSyncData() + localData := effectivePricingSyncData(getLocalPricingSyncData()) var testResults []dto.TestResult var successfulChannels []struct { @@ -518,16 +593,39 @@ func FetchUpstreamRatios(c *gin.Context) { successfulChannels = append(successfulChannels, struct { name string data map[string]any - }{name: r.Name, data: r.Data}) + }{name: r.Name, data: effectivePricingSyncData(r.Data)}) } } differences := buildDifferences(localData, successfulChannels) + type modelSyncPrices struct { + Current map[string]any `json:"current"` + Upstreams map[string]map[string]any `json:"upstreams"` + } + prices := make(map[string]modelSyncPrices, len(differences)) + for name, fields := range differences { + row := modelSyncPrices{Current: modelPricingSyncValues(localData, name), Upstreams: make(map[string]map[string]any)} + _, expressionPriority := fields[billing_setting.BillingExprField] + for _, channel := range successfulChannels { + candidate := modelPricingSyncValues(channel.data, name) + if expressionPriority && candidate[billing_setting.BillingModeField] != billing_setting.BillingModeTieredExpr { + continue + } + _, hasRatio := candidate["model_ratio"] + _, hasPrice := candidate["model_price"] + _, hasExpression := candidate[billing_setting.BillingExprField] + if hasRatio || hasPrice || hasExpression { + row.Upstreams[channel.name] = candidate + } + } + prices[name] = row + } c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "differences": differences, + "prices": prices, "test_results": testResults, }, }) @@ -538,6 +636,16 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { data map[string]any }) map[string]map[string]dto.DifferenceItem { differences := make(map[string]map[string]dto.DifferenceItem) + localData = effectivePricingSyncData(localData) + normalizedChannels := make([]struct { + name string + data map[string]any + }, 0, len(successfulChannels)) + for _, channel := range successfulChannels { + channel.data = effectivePricingSyncData(channel.data) + normalizedChannels = append(normalizedChannels, channel) + } + successfulChannels = normalizedChannels allModels := make(map[string]struct{}) @@ -591,19 +699,31 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } for modelName := range allModels { + expressionPriority := valueMap(localData[billing_setting.BillingModeField])[modelName] == billing_setting.BillingModeTieredExpr + for _, channel := range successfulChannels { + if valueMap(channel.data[billing_setting.BillingModeField])[modelName] == billing_setting.BillingModeTieredExpr { + expressionPriority = true + } + } for _, ratioType := range pricingSyncFields { - var localValue interface{} = nil + if expressionPriority && numericPricingSyncFields[ratioType] { + continue + } + var localValue any = nil if val, exists := valueMap(localData[ratioType])[modelName]; exists { localValue = normalizeSyncValue(ratioType, val) } - upstreamValues := make(map[string]interface{}) + upstreamValues := make(map[string]any) confidenceValues := make(map[string]bool) hasUpstreamValue := false hasDifference := false for _, channel := range successfulChannels { - var upstreamValue interface{} = nil + if expressionPriority && valueMap(channel.data[billing_setting.BillingModeField])[modelName] != billing_setting.BillingModeTieredExpr { + continue + } + var upstreamValue any = nil if val, exists := valueMap(channel.data[ratioType])[modelName]; exists { upstreamValue = normalizeSyncValue(ratioType, val) diff --git a/controller/ratio_sync_test.go b/controller/ratio_sync_test.go new file mode 100644 index 000000000000..b76446442226 --- /dev/null +++ b/controller/ratio_sync_test.go @@ -0,0 +1,142 @@ +package controller + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/setting/billing_setting" + "github.com/QuantumNous/new-api/setting/config" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" +) + +func TestPricingSyncExpressionPriority(t *testing.T) { + expression := `tier("base", p * 2 + c * 8 + cr * 0)` + cases := []struct { + name string + local map[string]any + source map[string]any + wantFields []string + }{ + {"equal expressions suppress stale ratios", map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}, "model_price": map[string]float64{"m": 2}}, nil}, + {"local expression excludes legacy-only source", map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}}, map[string]any{"model_ratio": map[string]float64{"m": 3}, "completion_ratio": map[string]float64{"m": 2}}, nil}, + {"expression imports without legacy conflicts", map[string]any{"model_ratio": map[string]float64{"m": 1}}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}, "model_price": map[string]float64{"m": 2}}, []string{"billing_mode", "billing_expr"}}, + {"inactive expression follows explicit ratio mode", map[string]any{"model_ratio": map[string]float64{"m": 1}}, map[string]any{"billing_mode": map[string]string{"m": "ratio"}, "billing_expr": map[string]string{"m": expression}, "model_ratio": map[string]float64{"m": 3}}, []string{"model_ratio"}}, + {"empty active expression never imports a false free price", map[string]any{}, map[string]any{"billing_mode": map[string]string{"m": "tiered_expr"}, "billing_expr": map[string]string{"m": " "}, "model_ratio": map[string]float64{"m": 0}}, nil}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + diff := buildDifferences(tt.local, []struct { + name string + data map[string]any + }{{"source", tt.source}}) + fields := make([]string, 0, len(diff["m"])) + for field := range diff["m"] { + fields = append(fields, field) + } + assert.ElementsMatch(t, tt.wantFields, fields) + }) + } +} + +func TestRatioConfigExportsEffectiveExpressions(t *testing.T) { + before := config.GlobalConfig.ExportAllConfigs() + expose := ratio_setting.IsExposeRatioEnabled() + t.Cleanup(func() { + config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": before["billing_setting.billing_mode"], "billing_expr": before["billing_setting.billing_expr"]}) + ratio_setting.SetExposeRatioEnabled(expose) + }) + config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": `{"sync-export":"tiered_expr"}`, "billing_expr": `{"sync-export":"tier(\"base\", p * 2)"}`}) + ratio_setting.SetExposeRatioEnabled(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/ratio_config", nil) + GetRatioConfig(c) + var response struct { + Success bool + Data map[string]any + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + assert.Equal(t, billing_setting.BillingModeTieredExpr, valueMap(response.Data["billing_mode"])["sync-export"]) + assert.Equal(t, `tier("base", p * 2)`, valueMap(response.Data["billing_expr"])["sync-export"]) +} + +func TestPricingSyncCompleteSourcesAndArrayFormats(t *testing.T) { + before := config.GlobalConfig.ExportAllConfigs() + oldRatios, oldCompletion := ratio_setting.ModelRatio2JSONString(), ratio_setting.CompletionRatio2JSONString() + t.Cleanup(func() { + config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": before["billing_setting.billing_mode"], "billing_expr": before["billing_setting.billing_expr"]}) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(oldRatios)) + require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(oldCompletion)) + }) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"sync-token":1}`)) + require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"sync-token":2}`)) + expression := `len <= 200000 ? tier("short", p * 2 + c * 8 + cr * 0) : tier("long", p * 4 + c * 12)` + expressions, err := common.Marshal(map[string]string{"sync-already": expression}) + require.NoError(t, err) + config.UpdateConfigFromMap(config.GlobalConfig.Get("billing_setting"), map[string]string{"billing_mode": `{"sync-already":"tiered_expr"}`, "billing_expr": string(expressions)}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var data any + if r.URL.Path == "/ratio_config" { + data = map[string]any{ + "billing_mode": map[string]string{"sync-already": "tiered_expr", "sync-expression": "tiered_expr"}, + "billing_expr": map[string]string{"sync-already": expression, "sync-expression": expression}, + "model_ratio": map[string]float64{"sync-already": 9, "sync-expression": 9, "sync-token": 1}, + "model_price": map[string]float64{"sync-expression": 4}, + "completion_ratio": map[string]float64{"sync-token": 4}, + "cache_ratio": map[string]float64{"sync-token": 0}, + } + } else { + data = []map[string]any{ + {"model_name": "sync-already", "model_ratio": 5, "completion_ratio": 3}, + {"model_name": "sync-expression", "model_ratio": 2, "model_price": 1}, + {"model_name": "sync-array-expression", "billing_mode": "tiered_expr", "billing_expr": expression, "quota_type": 1, "model_price": 0}, + {"model_name": "sync-unpriced"}, + {"model_name": "sync-invalid-expression", "billing_mode": "tiered_expr", "billing_expr": "", "model_ratio": 0}, + {"model_name": "sync-free", "model_ratio": 0, "completion_ratio": 0}, + } + } + encoded, err := common.Marshal(map[string]any{"success": true, "data": data}) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(encoded) + })) + defer server.Close() + var response struct { + Success bool + Data struct { + Differences map[string]map[string]dto.DifferenceItem + Prices map[string]struct { + Current map[string]any + Upstreams map[string]map[string]any + } + TestResults []dto.TestResult `json:"test_results"` + } + } + body := map[string]any{"upstreams": []map[string]any{ + {"id": 1, "name": "Expressions", "base_url": server.URL, "endpoint": "/ratio_config"}, + {"id": 2, "name": "Legacy", "base_url": server.URL, "endpoint": "/pricing"}, + }} + recorder := modelManagementRequest(t, FetchUpstreamRatios, http.MethodPost, "/api/channel/fetch_upstream_ratios", body, &response) + require.True(t, response.Success, recorder.Body.String()) + require.Len(t, response.Data.TestResults, 2) + for _, result := range response.Data.TestResults { + require.Equal(t, "success", result.Status, result.Error) + } + assert.NotContains(t, response.Data.Differences, "sync-already") + assert.NotContains(t, response.Data.Differences, "sync-unpriced") + assert.NotContains(t, response.Data.Differences, "sync-invalid-expression") + assert.Equal(t, map[string]any{"billing_mode": "tiered_expr", "billing_expr": expression}, response.Data.Prices["sync-expression"].Upstreams["Expressions(1)"]) + assert.NotContains(t, response.Data.Prices["sync-expression"].Upstreams, "Legacy(2)") + assert.Equal(t, map[string]any{"billing_mode": "tiered_expr", "billing_expr": expression}, response.Data.Prices["sync-array-expression"].Upstreams["Legacy(2)"]) + assert.Equal(t, float64(1), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["model_ratio"], "unchanged base prices are included for a complete price preview") + assert.Equal(t, float64(4), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["completion_ratio"]) + assert.Equal(t, float64(0), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["cache_ratio"]) + assert.Equal(t, float64(0), response.Data.Prices["sync-free"].Upstreams["Legacy(2)"]["model_ratio"]) +} diff --git a/controller/redemption.go b/controller/redemption.go index 86289f8a2dbf..a3c8dcef10c2 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -121,7 +121,7 @@ func AddRedemption(c *gin.Context) { } keys = append(keys, key) } - recordManageAudit(c, "redemption.create", map[string]interface{}{ + recordManageAudit(c, "redemption.create", map[string]any{ "name": redemption.Name, "count": redemption.Count, "quota": logger.LogQuota(redemption.Quota), @@ -215,3 +215,24 @@ func validateExpiredTime(c *gin.Context, expired int64) (bool, string) { } return true, "" } + +func DeleteRedemptionBatch(c *gin.Context) { + var request struct { + Ids []int `json:"ids" binding:"required,min=1,max=1000,dive,gt=0"` + } + if err := c.ShouldBindJSON(&request); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + count, err := model.BatchDeleteRedemptions(request.Ids) + if err != nil { + common.ApiError(c, err) + return + } + recordManageAudit(c, "redemption.delete_batch", map[string]any{ + "count": count, + "total": len(request.Ids), + "requested_redemption_ids": request.Ids, + }) + common.ApiSuccess(c, count) +} diff --git a/controller/redemption_batch_test.go b/controller/redemption_batch_test.go new file mode 100644 index 000000000000..8ff0d55c3721 --- /dev/null +++ b/controller/redemption_batch_test.go @@ -0,0 +1,201 @@ +package controller + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestDeleteRedemptionBatch(t *testing.T) { + for _, dialect := range []string{"sqlite", "mysql", "postgres"} { + t.Run(dialect, func(t *testing.T) { + var driver, logDriver gorm.Dialector + dbType := common.DatabaseTypeSQLite + switch dialect { + case "sqlite": + driver = sqlite.Open(":memory:") + logDriver = sqlite.Open(":memory:") + case "mysql": + dsn := os.Getenv("TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("TEST_MYSQL_DSN is not configured") + } + driver = mysql.Open(dsn) + logDSN := os.Getenv("TEST_MYSQL_LOG_DSN") + if logDSN == "" { + logDSN = dsn + } + logDriver = mysql.Open(logDSN) + dbType = common.DatabaseTypeMySQL + case "postgres": + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN is not configured") + } + driver = postgres.Open(dsn) + logDSN := os.Getenv("TEST_POSTGRES_LOG_DSN") + if logDSN == "" { + logDSN = dsn + } + logDriver = postgres.Open(logDSN) + dbType = common.DatabaseTypePostgreSQL + } + db, err := gorm.Open(driver, &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) + var version string + query := "SELECT version()" + if dialect == "sqlite" { + query = "SELECT sqlite_version()" + } + require.NoError(t, db.Raw(query).Scan(&version).Error) + t.Logf("database version: %s", version) + + logDB, err := gorm.Open(logDriver, &gorm.Config{}) + require.NoError(t, err) + logSQL, err := logDB.DB() + require.NoError(t, err) + logSQL.SetMaxOpenConns(1) + t.Cleanup(func() { require.NoError(t, logSQL.Close()) }) + previousDB, previousLogDB := model.DB, model.LOG_DB + previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType() + previousRedis := common.RedisEnabled + model.DB, model.LOG_DB = db, logDB + common.SetDatabaseTypes(dbType, dbType) + common.RedisEnabled = false + t.Cleanup(func() { + model.DB, model.LOG_DB = previousDB, previousLogDB + common.SetDatabaseTypes(previousMain, previousLog) + common.RedisEnabled = previousRedis + }) + for _, table := range []any{&model.User{}, &model.Redemption{}} { + require.False(t, db.Migrator().HasTable(table), "use an empty test database") + require.NoError(t, db.AutoMigrate(table)) + t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(table)) }) + } + require.False(t, logDB.Migrator().HasTable(&model.AuditLog{}), "use an empty test log database") + require.NoError(t, logDB.AutoMigrate(&model.AuditLog{})) + t.Cleanup(func() { require.NoError(t, logDB.Migrator().DropTable(&model.AuditLog{})) }) + token := "redemption-audit-test-token" + admin := model.User{Username: "redemption-audit-admin", Password: "unused", Role: common.RoleAdminUser, Status: common.UserStatusEnabled, Group: "default", AccessToken: &token} + require.NoError(t, db.Create(&admin).Error) + codes := make([]model.Redemption, 16) + for index := range codes { + codes[index] = model.Redemption{Name: "selected", Key: fmt.Sprintf("%032d", index+1), Quota: 100, Status: common.RedemptionCodeStatusEnabled} + } + codes[1].Status = common.RedemptionCodeStatusUsed + codes[15].Name = "unselected" + codes[15].Status = common.RedemptionCodeStatusDisabled + require.NoError(t, model.DB.Create(&codes).Error) + router := gin.New() + router.Use(middleware.RequestId()) + router.POST("/api/redemption/batch", middleware.AdminAuth(), DeleteRedemptionBatch) + + overLimit := make([]int, 1001) + for index := range overLimit { + overLimit[index] = codes[0].Id + } + oversized, err := common.Marshal(map[string]any{"ids": overLimit}) + require.NoError(t, err) + for _, body := range []string{"{}", `{"ids":[]}`, `{"ids":null}`, `{"ids":[0]}`, `{"ids":[1,-1]}`, `{"ids":["1"]}`, "{", string(oversized)} { + t.Run("invalid_"+body[:min(len(body), 30)], func(t *testing.T) { + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/redemption/batch", bytes.NewBufferString(body)) + request.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(response, request) + var result struct { + Success bool `json:"success"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.False(t, result.Success) + var count int64 + require.NoError(t, model.DB.Model(&model.Redemption{}).Count(&count).Error) + assert.EqualValues(t, 16, count) + var events []model.AuditLog + require.NoError(t, logDB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryOperation).Find(&events).Error) + require.Len(t, events, 1) + assert.False(t, events[0].Success) + assert.Equal(t, "redemption.delete_batch", events[0].Action) + }) + } + _, err = model.BatchDeleteRedemptions(nil) + require.Error(t, err) + requestedIDs := make([]int, 0, 17) + for _, code := range codes[:15] { + requestedIDs = append(requestedIDs, code.Id) + } + requestedIDs = append(requestedIDs, codes[0].Id, 999999) + payload, err := common.Marshal(map[string]any{"ids": requestedIDs}) + require.NoError(t, err) + for _, expectedCount := range []int64{15, 0} { + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/redemption/batch", bytes.NewReader(payload)) + request.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(response, request) + assert.Equal(t, http.StatusOK, response.Code) + var result struct { + Success bool `json:"success"` + Data int64 `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.True(t, result.Success) + assert.Equal(t, expectedCount, result.Data) + var events []model.AuditLog + require.NoError(t, logDB.Where("request_id = ? AND category = ?", response.Header().Get(common.RequestIdKey), model.AuditCategoryOperation).Find(&events).Error) + require.Len(t, events, 1, "one operation event, without a duplicate single-delete fallback") + event := events[0] + assert.Equal(t, "redemption.delete_batch", event.Action) + assert.Equal(t, fmt.Sprintf("Batch deleted %d redemption codes", expectedCount), event.Content) + assert.True(t, event.Success) + assert.Equal(t, admin.Id, event.UserId) + assert.Equal(t, "/api/redemption/batch", event.Route) + require.NotNil(t, event.Other.Op) + encoded, err := common.Marshal(event.Other.Op.Params) + require.NoError(t, err) + var params struct { + Count int64 `json:"count"` + Total int `json:"total"` + IDs []int `json:"requested_redemption_ids"` + } + require.NoError(t, common.Unmarshal(encoded, ¶ms)) + assert.Equal(t, expectedCount, params.Count) + assert.Equal(t, len(requestedIDs), params.Total) + assert.Equal(t, requestedIDs, params.IDs) + encoded, err = common.Marshal(event) + require.NoError(t, err) + assert.NotContains(t, string(encoded), token) + for _, code := range codes { + assert.NotContains(t, string(encoded), code.Key) + } + } + var active []model.Redemption + require.NoError(t, model.DB.Find(&active).Error) + require.Len(t, active, 1) + assert.Equal(t, codes[15], active[0]) + var all []model.Redemption + require.NoError(t, model.DB.Unscoped().Order("id").Find(&all).Error) + require.Len(t, all, 16) + for _, code := range all[:15] { + assert.True(t, code.DeletedAt.Valid) + } + assert.False(t, all[15].DeletedAt.Valid) + }) + } +} diff --git a/controller/relay.go b/controller/relay.go index 02ded6a566a3..b8f21b22ff07 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -486,6 +486,11 @@ func RelayNotImplemented(c *gin.Context) { } func RelayNotFound(c *gin.Context) { + // The web fallback may already have applied static-asset cache headers. + // A missing API or asset can appear after an upgrade; never cache its 404. + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") err := types.OpenAIError{ Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), Type: "invalid_request_error", diff --git a/controller/relay_error_log_test.go b/controller/relay_error_log_test.go index 737c05805959..b214a7d4cede 100644 --- a/controller/relay_error_log_test.go +++ b/controller/relay_error_log_test.go @@ -80,9 +80,9 @@ func TestProcessChannelErrorUsesSnapshotWithoutLeakingChannelMetadata(t *testing for _, key := range []string{"channel_id", "channel_name", "channel_type"} { assert.NotContains(t, storedOther, key) } - adminInfo, ok := storedOther["admin_info"].(map[string]interface{}) + adminInfo, ok := storedOther["admin_info"].(map[string]any) require.True(t, ok) - assert.Equal(t, []interface{}{"101"}, adminInfo["use_channel"]) + assert.Equal(t, []any{"101"}, adminInfo["use_channel"]) logs, total, err := model.GetUserLogs(7, model.LogTypeError, 0, 0, "", "", 0, 10, "", "", "") require.NoError(t, err) diff --git a/controller/secure_verification.go b/controller/secure_verification.go index f4d4ba8299a6..06dbd6b88cce 100644 --- a/controller/secure_verification.go +++ b/controller/secure_verification.go @@ -2,87 +2,135 @@ package controller import ( "errors" - "fmt" "net/http" - "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/oauth" "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" ) -const ( - secureVerificationMethod2FA = "2fa" - secureVerificationMethodPasskey = "passkey" -) - -type UniversalVerifyRequest struct { - Method string `json:"method"` - Code string `json:"code,omitempty"` - Scope string `json:"scope"` -} - -func UniversalVerify(c *gin.Context) { +func GetVerificationMethods(c *gin.Context) { identity, ok := middleware.GetSessionAuthIdentity(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"}) return } - var request UniversalVerifyRequest - if err := common.DecodeJson(c.Request.Body, &request); err != nil { - common.ApiError(c, fmt.Errorf("参数错误: %v", err)) - return - } - if request.Method != secureVerificationMethod2FA { - common.ApiError(c, errors.New("Passkey 验证必须使用 Passkey verify 流程")) - return - } - if !isAllowedSecurityProofScope(request.Scope) { - common.ApiError(c, errors.New("不支持的安全验证范围")) + requirements, err := service.GetVerificationRequirements(identity, c.Query("scope")) + if err != nil { + writeSecurityOperationError(c, err) return } - if strings.TrimSpace(request.Code) == "" { - common.ApiError(c, errors.New("验证码不能为空")) + common.ApiSuccess(c, requirements) +} + +// writeSecurityOperationError only exposes known, fixed business messages. +// Unexpected errors retain their cause for the existing server-side auth logger. +func writeSecurityOperationError(c *gin.Context, err error) { + status := http.StatusOK + var code, message string + var protocolError *protocol.Error + switch { + case errors.Is(err, service.ErrAccountEmailInvalid), errors.Is(err, service.ErrAccountEmailRestricted): + code, message = "EMAIL_ADDRESS_REJECTED", err.Error() + case errors.Is(err, model.ErrEmailAlreadyTaken): + code, message = "EMAIL_ALREADY_TAKEN", "This email address is already in use." + case errors.Is(err, service.ErrEmailBindingDelivery): + code, message = "EMAIL_BINDING_DELIVERY_FAILED", err.Error() + case errors.Is(err, model.ErrEmailBindingCodeInvalid): + code, message = "EMAIL_BINDING_CODE_INVALID", err.Error() + case errors.Is(err, model.ErrEmailBindingLocked): + code, message = "EMAIL_BINDING_LOCKED", err.Error() + case errors.Is(err, model.ErrEmailBindingResendWait): + status = http.StatusTooManyRequests + code, message = "EMAIL_BINDING_RESEND_WAIT", err.Error() + case errors.Is(err, common.ErrAccountPasswordLength), errors.Is(err, common.ErrAccountPasswordSame), errors.Is(err, common.ErrPasswordLegacyLimit): + code, message = "PASSWORD_POLICY_REJECTED", err.Error() + case errors.Is(err, model.ErrCurrentPasswordInvalid): + code, message = "CURRENT_PASSWORD_INVALID", err.Error() + case errors.Is(err, model.ErrAccountPasswordState), errors.Is(err, model.ErrAccountBindingChanged): + status = http.StatusConflict + code, message = "ACCOUNT_SECURITY_STATE_CHANGED", err.Error() + case errors.Is(err, model.ErrLastLoginMethod): + code, message = "LAST_LOGIN_METHOD", err.Error() + case errors.Is(err, oauth.ErrTelegramOAuthNotConfigured): + code, message = "TELEGRAM_OAUTH_NOT_CONFIGURED", oauth.ErrTelegramOAuthNotConfigured.Error() + case errors.Is(err, oauth.ErrTelegramOAuthConflict): + code, message = "TELEGRAM_OAUTH_CONFLICT", oauth.ErrTelegramOAuthConflict.Error() + case errors.Is(err, oauth.ErrTelegramOAuthFailed): + code, message = "TELEGRAM_OAUTH_FAILED", oauth.ErrTelegramOAuthFailed.Error() + case errors.Is(err, oauth.ErrTelegramAccountNotBound): + code, message = "TELEGRAM_ACCOUNT_NOT_BOUND", oauth.ErrTelegramAccountNotBound.Error() + case errors.Is(err, model.ErrExternalIdentityAlreadyClaimed): + code, message = "ACCOUNT_ALREADY_BOUND", "This external account is already bound." + if c.Param("provider") == "telegram" { + code, message = "TELEGRAM_BIND_ALREADY_BOUND", "This Telegram account is already bound." + } + case errors.Is(err, service.ErrVerificationContextInvalid): + status = http.StatusBadRequest + code, message = "SECURITY_CONTEXT_INVALID", service.ErrVerificationContextInvalid.Error() + case errors.Is(err, service.ErrVerificationForbidden): + status = http.StatusForbidden + code, message = "SECURITY_ACTION_FORBIDDEN", service.ErrVerificationForbidden.Error() + case errors.Is(err, service.ErrVerificationFailed), errors.As(err, &protocolError): + code, message = "SECURITY_VERIFICATION_FAILED", service.ErrVerificationFailed.Error() + case errors.Is(err, service.ErrVerificationLocked): + code, message = "SECURITY_VERIFICATION_LOCKED", service.ErrVerificationLocked.Error() + case errors.Is(err, service.ErrVerificationUnavailable): + code, message = "SECURITY_METHOD_UNAVAILABLE", service.ErrVerificationUnavailable.Error() + case errors.Is(err, service.ErrVerificationFlowRequired): + status = http.StatusBadRequest + code, message = "SECURITY_VERIFICATION_FLOW_REQUIRED", service.ErrVerificationFlowRequired.Error() + case errors.Is(err, service.ErrProofMethod): + code, message = "SECURITY_PROOF_METHOD_MISMATCH", "This verification method is not allowed for this action." + case errors.Is(err, service.ErrProofScope): + code, message = "SECURITY_PROOF_SCOPE_MISMATCH", "Verification does not match this action." + case errors.Is(err, service.ErrOAuthAccountMismatch): + code, message = "OAUTH_ACCOUNT_MISMATCH", service.ErrOAuthAccountMismatch.Error() + case errors.Is(err, model.ErrTwoFASetupInvalid): + status = http.StatusConflict + code, message = "TWOFA_SETUP_INVALID", model.ErrTwoFASetupInvalid.Error() + case errors.Is(err, model.ErrTwoFACodeInvalid): + code, message = "TWOFA_CODE_INVALID", model.ErrTwoFACodeInvalid.Error() + case errors.Is(err, model.ErrTwoFAAlreadyEnabled): + code, message = "TWOFA_ALREADY_ENABLED", "Two-factor authentication is already enabled." + case errors.Is(err, model.ErrTwoFANotEnabled): + code, message = "TWOFA_NOT_ENABLED", "Two-factor authentication is not enabled." + case errors.Is(err, model.ErrPasskeyNotFound): + code, message = "PASSKEY_NOT_FOUND", "No Passkey is registered." + case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed): + code, message = "AUTH_FLOW_INVALID", "Verification flow expired" + case errors.Is(err, model.ErrUserSessionInvalid), errors.Is(err, model.ErrUserSessionInactive): + writeAuthSessionError(c, service.ErrAuthTokenInvalid) return - } - twoFA, err := model.GetTwoFAByUserId(identity.UserID) - if err != nil { - common.ApiError(c, err) + default: + c.Set("security_error_code", "AUTH_INTERNAL_ERROR") + writeAuthSessionError(c, err) return } - if twoFA == nil || !twoFA.IsEnabled { - common.ApiError(c, errors.New("用户未启用2FA")) + c.Set("security_error_code", code) + c.JSON(status, gin.H{"success": false, "code": code, "message": message}) +} + +func UniversalVerify(c *gin.Context) { + identity, ok := middleware.GetSessionAuthIdentity(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"}) return } - if !validateTwoFactorAuth(twoFA, request.Code) { - common.ApiError(c, errors.New("验证失败,请检查验证码")) + var request service.VerificationInput + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + common.ApiErrorMsg(c, "参数错误") return } - proofToken, expiresAt, err := service.IssueSecurityProof(identity, request.Method, []string{request.Scope}) + proof, err := service.VerifySecurityInput(identity, request) if err != nil { - common.ApiError(c, err) + writeSecurityOperationError(c, err) return } - model.RecordLog(identity.UserID, model.LogTypeSystem, "通用安全验证成功 (验证方式: 2FA)") - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "验证成功", - "data": gin.H{ - "proof_token": proofToken, - "expires_at": expiresAt, - "method": request.Method, - "scope": request.Scope, - }, - }) -} - -func isAllowedSecurityProofScope(scope string) bool { - switch scope { - case securityProofScopeChannelKeyRead, securityProofScopePasskeyRegister, securityProofScopePasskeyDelete: - return true - default: - return false - } + recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope}) + common.ApiSuccess(c, proof) } diff --git a/controller/security_account_test.go b/controller/security_account_test.go new file mode 100644 index 000000000000..e76ab4c6b338 --- /dev/null +++ b/controller/security_account_test.go @@ -0,0 +1,847 @@ +package controller + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "io" + "net" + "net/http" + "net/textproto" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestSecurityAccountDeletionRequiresScopedProof(t *testing.T) { + for _, scenario := range []string{"missing", "wrong scope", "expired", "consumed", "other session", "other account", "password disabled", "factor added"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + originalIdentity := identity + operation := service.VerificationOperation{Scope: service.VerificationScopeAccountDelete} + proof := "" + if scenario != "missing" { + proof = issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword) + } + if scenario == "wrong scope" { + proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword) + } + switch scenario { + case "expired": + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error) + case "consumed": + _, err := service.ConsumeOperationProof(proof, identity, operation) + require.NoError(t, err) + case "other session", "other account": + userID := user.Id + if scenario == "other account" { + other := &model.User{Username: "other", AffCode: "other", Group: "default", Password: user.Password, Status: common.UserStatusEnabled, Role: common.RoleCommonUser, AuthVersion: 1} + require.NoError(t, model.DB.Create(other).Error) + userID = other.Id + } + bundle, err := service.CreateLoginSession(userID, "password", "127.0.0.1", scenario) + require.NoError(t, err) + identity, err = service.ParseAccessToken(bundle.AccessToken) + require.NoError(t, err) + case "password disabled": + previous := common.PasswordLoginEnabled + common.PasswordLoginEnabled = false + t.Cleanup(func() { common.PasswordLoginEnabled = previous }) + case "factor added": + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error) + } + response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf) + assert.Equal(t, http.StatusForbidden, response.Code, response.Body.String()) + stored, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + assert.Equal(t, identity.UserAuthVersion, stored.AuthVersion) + _, _, err = service.ValidateLoginSession(originalIdentity) + assert.NoError(t, err) + var audit model.AuditLog + require.NoError(t, model.LOG_DB.Where("action = ?", "user.account_delete").Last(&audit).Error) + assert.False(t, audit.Success) + auditJSON, err := common.Marshal(audit) + require.NoError(t, err) + if proof != "" { + assert.NotContains(t, string(auditJSON), proof) + } + }) + } +} + +func TestSecurityAccountDeletionAcceptsEitherFactorAndRevokesSessions(t *testing.T) { + for _, method := range []string{"password", "oauth", "2fa", "passkey"} { + t.Run(method, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + if method == "oauth" { + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error) + oauth.Register("account-delete-oauth", &enrollmentOAuthProvider{externalID: "linked-user"}) + t.Cleanup(func() { oauth.Unregister("account-delete-oauth") }) + } + if method == "2fa" || method == "passkey" { + newSecurityLoginPasskey(t, user.Id) + factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + if method == "passkey" { + locked := time.Now().Add(time.Minute) + factor.LockedUntil = &locked + } else { + system_setting.GetPasskeySettings().Enabled = false + } + require.NoError(t, model.DB.Create(factor).Error) + } + requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAccountDelete) + require.NoError(t, err) + _, err = service.RequireVerificationMethod(identity, service.VerificationScopeAccountDelete, method) + require.NoError(t, err) + if method == "2fa" || method == "passkey" { + assert.Len(t, requirements.Methods, 2) + _, err = service.RequireVerificationMethod(identity, service.VerificationScopeAccountDelete, "password") + assert.ErrorIs(t, err, service.ErrProofMethod) + } + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}, method) + if method == "password" || method == "2fa" { + input := service.VerificationInput{Scope: service.VerificationScopeAccountDelete, Method: method, Password: "enrollment-password"} + if method == "2fa" { + input.Code, err = totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now()) + require.NoError(t, err) + } + verified, err := service.VerifySecurityInput(identity, input) + require.NoError(t, err) + proof = verified.ProofToken + } + otherSession, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "second-session") + require.NoError(t, err) + require.NoError(t, model.UpdateUserAccessToken(user.Id, "account-delete-access-token")) + response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + assert.Contains(t, response.Header().Get("Set-Cookie"), "Max-Age=0") + assert.Contains(t, response.Header().Get("Cache-Control"), "no-store") + var deleted model.User + require.NoError(t, model.DB.Unscoped().First(&deleted, user.Id).Error) + assert.True(t, deleted.DeletedAt.Valid) + assert.Equal(t, user.AuthVersion+1, deleted.AuthVersion) + _, _, err = service.ValidateLoginSession(identity) + assert.Error(t, err) + otherIdentity, err := service.ParseAccessToken(otherSession.AccessToken) + require.NoError(t, err) + _, _, err = service.ValidateLoginSession(otherIdentity) + assert.Error(t, err) + _, _, err = service.RefreshLoginSession(otherSession.RefreshToken, otherIdentity.SessionID, "127.0.0.1", "second-session") + assert.Error(t, err) + count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix()) + require.NoError(t, err) + assert.Zero(t, count) + tokenUser, err := model.ValidateAccessToken("account-delete-access-token") + require.NoError(t, err) + assert.Nil(t, tokenUser) + var audit model.AuditLog + require.NoError(t, model.LOG_DB.Where("action = ?", "user.account_delete").Last(&audit).Error) + assert.True(t, audit.Success) + }) + } +} + +func TestSecurityAccountDeletionRechecksTransactionAndConsumesFailedProof(t *testing.T) { + for _, scenario := range []string{"revoked session", "auth version", "root", "write failure"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + operation := service.VerificationOperation{Scope: service.VerificationScopeAccountDelete} + proof := issueSecurityEnrollmentProof(t, identity, operation, "password") + if scenario != "write failure" { + _, err := service.ConsumeOperationProof(proof, identity, operation) + require.NoError(t, err) + } + switch scenario { + case "revoked session": + _, err := model.RevokeAllUserSessions(user.Id, "test") + require.NoError(t, err) + case "auth version": + require.NoError(t, model.DB.Model(user).Update("auth_version", user.AuthVersion+1).Error) + case "root": + require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error) + _, err := service.GetVerificationRequirements(identity, service.VerificationScopeAccountDelete) + assert.ErrorIs(t, err, service.ErrVerificationForbidden) + case "write failure": + require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register("account-delete-failure", func(tx *gorm.DB) { + if tx.Statement.Table == "users" { + _ = tx.AddError(errors.New("injected deletion failure")) + } + })) + response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf) + assert.Contains(t, response.Body.String(), `"success":false`) + require.NoError(t, model.DB.Callback().Delete().Remove("account-delete-failure")) + response = securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf) + assert.Contains(t, response.Body.String(), "SECURITY_PROOF_CONSUMED") + } + if scenario != "write failure" { + assert.Error(t, model.DeleteUserForSession(identity)) + } + _, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + if scenario == "write failure" { + _, _, err = service.ValidateLoginSession(identity) + assert.NoError(t, err, "failed deletion must roll back the account version and preserve sessions") + } + }) + } +} + +func TestSecurityAccountDeletionConcurrentRequestsHaveOneWinner(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}, "password") + start := make(chan struct{}) + responses := make(chan string, 2) + for range 2 { + go func() { + <-start + response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf) + responses <- response.Body.String() + }() + } + close(start) + succeeded := 0 + for range 2 { + var response securityEnrollmentResponse + require.NoError(t, common.UnmarshalJsonStr(<-responses, &response)) + if response.Success { + succeeded++ + } + } + assert.Equal(t, 1, succeeded) + var deleted model.User + require.NoError(t, model.DB.Unscoped().First(&deleted, user.Id).Error) + assert.True(t, deleted.DeletedAt.Valid) + assert.Equal(t, identity.UserAuthVersion+1, deleted.AuthVersion) +} + +type securityMailbox struct { + mutex sync.Mutex + mail map[string][]string +} + +// Use a real local SMTP boundary so delivery, failure and code handling are +// exercised without exporting test-only mail hooks from production packages. +func newSecurityMailbox(t *testing.T) *securityMailbox { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + mailbox := &securityMailbox{mail: map[string][]string{}} + previousServer, previousPort := common.SMTPServer, common.SMTPPort + previousAccount, previousFrom, previousToken := common.SMTPAccount, common.SMTPFrom, common.SMTPToken + previousSSL, previousTLS := common.SMTPSSLEnabled, common.SMTPStartTLSEnabled + common.SMTPServer, common.SMTPPort = "127.0.0.1", listener.Addr().(*net.TCPAddr).Port + common.SMTPAccount, common.SMTPFrom, common.SMTPToken = "", "sender@example.com", "" + common.SMTPSSLEnabled, common.SMTPStartTLSEnabled = false, false + done := make(chan struct{}) + go func() { + defer close(done) + for { + connection, err := listener.Accept() + if err != nil { + return + } + mailbox.receive(connection) + } + }() + t.Cleanup(func() { + _ = listener.Close() + <-done + common.SMTPServer, common.SMTPPort = previousServer, previousPort + common.SMTPAccount, common.SMTPFrom, common.SMTPToken = previousAccount, previousFrom, previousToken + common.SMTPSSLEnabled, common.SMTPStartTLSEnabled = previousSSL, previousTLS + }) + return mailbox +} + +func (mailbox *securityMailbox) receive(connection net.Conn) { + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(10 * time.Second)) + client := textproto.NewConn(connection) + if client.PrintfLine("220 localhost ESMTP") != nil { + return + } + receiver := "" + for { + line, err := client.ReadLine() + if err != nil { + return + } + switch { + case strings.HasPrefix(line, "RCPT TO:"): + receiver = strings.TrimSuffix(strings.TrimPrefix(line, "RCPT TO:<"), ">") + case line == "DATA": + if client.PrintfLine("354 Send message") != nil { + return + } + message, err := io.ReadAll(client.DotReader()) + if err != nil { + return + } + mailbox.mutex.Lock() + mailbox.mail[receiver] = append(mailbox.mail[receiver], string(message)) + mailbox.mutex.Unlock() + case line == "QUIT": + _ = client.PrintfLine("221 Goodbye") + return + } + if client.PrintfLine("250 OK") != nil { + return + } + } +} + +func (mailbox *securityMailbox) code(t *testing.T, receiver string) string { + t.Helper() + mailbox.mutex.Lock() + defer mailbox.mutex.Unlock() + for index := len(mailbox.mail[receiver]) - 1; index >= 0; index-- { + match := regexp.MustCompile(`([0-9]{6})`).FindStringSubmatch(mailbox.mail[receiver][index]) + if len(match) == 2 { + return match[1] + } + } + t.Fatalf("no verification email delivered to %s", receiver) + return "" +} + +func startSecurityEmailBinding(t *testing.T, identity service.AuthIdentity, email, method string) service.EmailBindingData { + t.Helper() + context, err := common.Marshal(service.AccountBindingContext{Provider: "email", Email: email}) + require.NoError(t, err) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, method) + request, err := common.Marshal(map[string]string{"email": email}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/oauth/email/bind/start", string(request), proof, identity, EmailBindStart) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + var flow service.EmailBindingData + require.NoError(t, common.Unmarshal(body.Data, &flow)) + require.NotEmpty(t, flow.FlowToken) + return flow +} + +func TestSecurityAccountRequiresProofBeforeMutation(t *testing.T) { + for _, test := range []struct { + name, path, body string + handler gin.HandlerFunc + }{ + {"password", "/api/user/self", `{"password":"account-password!42","original_password":"enrollment-password"}`, UpdateSelf}, + {"oauth binding", "/api/oauth/state", `{"provider":"security-account-test","intent":"bind"}`, GenerateOAuthCode}, + } { + t.Run(test.name, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + oauth.Register("security-account-test", &authFlowTestOAuthProvider{}) + t.Cleanup(func() { oauth.Unregister("security-account-test") }) + response := securityEnrollmentRequest(http.MethodPost, test.path, test.body, "", identity, test.handler) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, http.StatusForbidden, response.Code) + assert.Equal(t, "SECURITY_PROOF_REQUIRED", result.Code) + stored, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.True(t, common.ValidatePasswordAndHash("enrollment-password", stored.Password)) + var flows int64 + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error) + assert.Zero(t, flows) + }) + } +} + +func TestSecurityAccountPasswordRequiresCurrentPassword(t *testing.T) { + for _, test := range []struct { + name, original string + success bool + }{ + {"missing", "", false}, + {"wrong", "wrong-password", false}, + {"correct", "enrollment-password", true}, + } { + t.Run(test.name, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword) + request, err := common.Marshal(map[string]string{"password": "password123", "original_password": test.original}) + require.NoError(t, err) + response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(request), proof, identity, UpdateSelf) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, test.success, result.Success, response.Body.String()) + stored, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + if test.success { + assert.True(t, common.ValidatePasswordAndHash("password123", stored.Password)) + assert.True(t, strings.HasPrefix(stored.Password, "$argon2id$")) + } else { + assert.Equal(t, "CURRENT_PASSWORD_INVALID", result.Code) + assert.Equal(t, user.Password, stored.Password) + repeated := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(request), proof, identity, UpdateSelf) + require.NoError(t, common.Unmarshal(repeated.Body.Bytes(), &result)) + assert.Equal(t, "SECURITY_PROOF_CONSUMED", result.Code) + } + }) + } +} + +func TestSecurityAccountProfileReadsPasswordStatusInOneQuery(t *testing.T) { + for _, hasPassword := range []bool{true, false} { + name := "password set" + if !hasPassword { + name = "password unset" + } + t.Run(name, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + updates := map[string]any{"access_token": "private-profile-token", "remark": "private-profile-remark"} + if !hasPassword { + updates["password"] = "" + } + require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Updates(updates).Error) + queries := 0 + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("profile_query_count", func(tx *gorm.DB) { + queries++ + })) + profile, err := model.GetSelfUserById(user.Id) + require.NoError(t, err) + assert.Equal(t, 1, queries) + assert.Equal(t, hasPassword, profile.HasPassword) + assert.Empty(t, profile.Password) + assert.Nil(t, profile.AccessToken) + assert.Empty(t, profile.Remark) + assert.Equal(t, user.AuthVersion, profile.AuthVersion) + assert.Equal(t, user.Group, profile.Group) + require.NoError(t, model.DB.Callback().Query().Remove("profile_query_count")) + + response := securityEnrollmentRequest(http.MethodGet, "/api/user/self", "", "", identity, GetSelf) + var result struct { + Success bool `json:"success"` + Data map[string]any `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + assert.Equal(t, hasPassword, result.Data["has_password"]) + assert.NotContains(t, result.Data, "password") + assert.NotContains(t, result.Data, "access_token") + assert.NotContains(t, result.Data, "remark") + + bundle, err := service.CreateLoginSession(user.Id, "profile-test", "127.0.0.1", "profile-test") + require.NoError(t, err) + _, refreshed, err := service.RefreshLoginSession(bundle.RefreshToken, "", "127.0.0.1", "profile-test") + require.NoError(t, err) + assert.Equal(t, hasPassword, refreshed.HasPassword) + assert.Empty(t, refreshed.Password) + assert.Nil(t, refreshed.AccessToken) + }) + } +} + +func TestSecurityAccountProfileUpdateDoesNotRequireProof(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", `{"display_name":"Updated"}`, "", identity, UpdateSelf) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + stored, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.Equal(t, "Updated", stored.DisplayName) + assert.Equal(t, user.Password, stored.Password) +} + +func TestSecurityAccountLongUnicodePasswordAndSessionRotation(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-device") + require.NoError(t, err) + otherIdentity, err := service.ParseAccessToken(other.AccessToken) + require.NoError(t, err) + password := strings.Repeat("安全🔒 ", 24) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword) + body, err := common.Marshal(map[string]string{"password": password, "original_password": "enrollment-password"}) + require.NoError(t, err) + response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(body), proof, identity, UpdateSelf) + var result struct { + Success bool `json:"success"` + Data struct { + AccessToken string `json:"access_token"` + HasPassword bool `json:"has_password"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + assert.True(t, result.Data.HasPassword) + currentIdentity, err := service.ParseAccessToken(result.Data.AccessToken) + require.NoError(t, err) + _, _, err = service.ValidateLoginSession(currentIdentity) + require.NoError(t, err) + _, _, err = service.ValidateLoginSession(otherIdentity) + require.Error(t, err) + login := model.User{Username: user.Username, Password: password} + require.NoError(t, login.ValidateAndFill()) + assert.False(t, common.ValidatePasswordAndHash(strings.TrimSpace(password), login.Password)) + assert.False(t, common.ValidatePasswordAndHash("enrollment-password", login.Password)) +} + +func TestSecurityAccountPasswordPolicyAndHashCompatibility(t *testing.T) { + t.Setenv("ACCOUNT_PASSWORD_HASH_ALGORITHM", "argon2id") + for _, test := range []struct { + name, password string + valid bool + }{ + {"common password accepted", "password123", true}, + {"short", "seven77", false}, + {"too long", strings.Repeat("界", 129), false}, + {"long Unicode", strings.Repeat("界🔒 ", 40), true}, + {"whitespace preserved", " phrase with whitespace ", true}, + } { + t.Run(test.name, func(t *testing.T) { + hash, err := common.HashAccountPassword(test.password) + if !test.valid { + require.Error(t, err) + assert.Empty(t, hash) + return + } + require.NoError(t, err) + assert.True(t, common.ValidatePasswordAndHash(test.password, hash)) + assert.False(t, common.ValidatePasswordAndHash(test.password+"x", hash)) + }) + } + legacy, err := common.Password2Hash("123456") + require.NoError(t, err) + assert.True(t, common.ValidatePasswordAndHash("123456", legacy), "login preserves historical passwords without applying new policy") + for _, invalid := range []string{"$argon2id$", "$argon2id$v=19$m=4294967295,t=2,p=1$bad$bad", "$argon2id$v=19$m=19456,t=2,p=1$bad$bad"} { + assert.False(t, common.ValidatePasswordAndHash("example-password", invalid)) + } +} + +func TestSecurityAccountEncryptedLongPasswordLogin(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + password := strings.Repeat("🔒", 128) + hash, err := common.HashAccountPassword(password) + require.NoError(t, err) + require.NoError(t, model.DB.Model(user).Update("password", hash).Error) + keyID, publicPEM := common.PasswordEncryptionPublicKey() + if keyID == "" { + privatePEM, err := common.GeneratePasswordEncryptionPrivateKey() + require.NoError(t, err) + require.NoError(t, common.LoadPasswordEncryptionPrivateKey(privatePEM)) + keyID, publicPEM = common.PasswordEncryptionPublicKey() + } + block, _ := pem.Decode([]byte(publicPEM)) + require.NotNil(t, block) + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + require.NoError(t, err) + publicKey, ok := parsed.(*rsa.PublicKey) + require.True(t, ok) + key, nonce := make([]byte, 32), make([]byte, 12) + _, err = rand.Read(key) + require.NoError(t, err) + _, err = rand.Read(nonce) + require.NoError(t, err) + wrappedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, key, []byte("password-v2")) + require.NoError(t, err) + aesBlock, err := aes.NewCipher(key) + require.NoError(t, err) + gcm, err := cipher.NewGCM(aesBlock) + require.NoError(t, err) + ciphertext := gcm.Seal(nil, nonce, []byte(password), []byte("password-v2:"+keyID)) + parts := []string{"v2", base64.StdEncoding.EncodeToString(wrappedKey), base64.StdEncoding.EncodeToString(nonce), base64.StdEncoding.EncodeToString(ciphertext)} + encrypted := strings.Join(parts, ".") + common.PasswordLoginEncryptionEnabled = true + request, err := common.Marshal(LoginRequest{Username: user.Username, PasswordEncrypted: encrypted, EncryptionKeyID: keyID}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/login", string(request), "", identity, Login) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + assert.Contains(t, string(result.Data), `"has_password":true`) + proof, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "password", Scope: service.VerificationScopePasswordChange, PasswordEncrypted: encrypted, EncryptionKeyID: keyID}) + require.NoError(t, err) + assert.NotEmpty(t, proof.ProofToken) + ciphertext[len(ciphertext)-1] ^= 1 + parts[3] = base64.StdEncoding.EncodeToString(ciphertext) + for _, invalid := range []string{strings.Join(parts, "."), "v2.bad.bad.bad", "v2." + strings.Repeat("a", 4096)} { + _, err := common.DecryptPassword(invalid, keyID) + assert.ErrorIs(t, err, common.ErrPasswordEncryptionInvalid) + } + _, err = common.DecryptPassword(encrypted, "wrong-key-id") + assert.ErrorIs(t, err, common.ErrPasswordEncryptionInvalid) +} + +func TestSecurityAccountEmailConfirmationAndAudit(t *testing.T) { + for _, scenario := range []string{"first email", "replace without mfa", "replace with mfa"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + mailbox := newSecurityMailbox(t) + method, previous := service.VerificationMethodPassword, "" + if scenario != "first email" { + previous = "previous@example.com" + require.NoError(t, model.DB.Model(user).Update("email", previous).Error) + } + if scenario == "replace with mfa" { + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error) + method = service.VerificationMethodTwoFA + } + flow := startSecurityEmailBinding(t, identity, "New@Example.com", method) + assert.Equal(t, "new@example.com", flow.Email) + assert.Equal(t, scenario == "replace without mfa", flow.OldEmailRequired) + newCode, oldCode := mailbox.code(t, flow.Email), "" + _, state, err := model.GetEmailBinding(identity, flow.FlowToken) + require.NoError(t, err) + assert.NotEqual(t, newCode, state.NewCodeHash) + assert.True(t, common.ValidatePasswordAndHash(newCode, state.NewCodeHash)) + if flow.OldEmailRequired { + oldCode = mailbox.code(t, previous) + _, err := service.FinishEmailBinding(identity, flow.FlowToken, newCode, "") + assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid) + stored, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + assert.Equal(t, previous, stored.Email) + } + request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": newCode, "old_code": oldCode}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + stored, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + assert.Equal(t, flow.Email, stored.Email) + _, err = service.FinishEmailBinding(identity, flow.FlowToken, newCode, oldCode) + assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) + legacy := securityEnrollmentRequest("POST", "/api/oauth/email/bind", `{"email":"other@example.com","code":"123456"}`, "", identity, EmailBind) + assert.Contains(t, legacy.Body.String(), `"success":false`) + var audits []model.AuditLog + require.NoError(t, model.LOG_DB.Find(&audits).Error) + require.NotEmpty(t, audits) + assert.False(t, audits[len(audits)-1].Success, "a rejected legacy request must be recorded as failed") + encoded, err := common.Marshal(audits) + require.NoError(t, err) + assert.Contains(t, string(encoded), "user.binding_bind") + assert.NotContains(t, string(encoded), flow.FlowToken) + assert.NotContains(t, string(encoded), newCode) + }) + } +} + +func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + mailbox := newSecurityMailbox(t) + flow := startSecurityEmailBinding(t, identity, "new@example.com", service.VerificationMethodPassword) + _, err := service.ResendAccountEmailBinding(identity, flow.FlowToken) + assert.ErrorIs(t, err, model.ErrEmailBindingResendWait) + _, err = service.FinishEmailBinding(identity, flow.FlowToken, "invalid", "") + assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid) + stored, state, err := model.GetEmailBinding(identity, flow.FlowToken) + require.NoError(t, err) + oldHash := state.NewCodeHash + state.ResendAt = time.Now().Add(-time.Second).Unix() + payload, err := common.Marshal(state) + require.NoError(t, err) + require.NoError(t, model.DB.Model(stored).Update("payload", string(payload)).Error) + replacement, err := service.ResendAccountEmailBinding(identity, flow.FlowToken) + require.NoError(t, err) + assert.Equal(t, flow.ExpiresAt, replacement.ExpiresAt) + _, state, err = model.GetEmailBinding(identity, flow.FlowToken) + require.NoError(t, err) + assert.Equal(t, 1, state.FailedAttempts) + assert.NotEqual(t, oldHash, state.NewCodeHash) + assert.True(t, common.ValidatePasswordAndHash(mailbox.code(t, flow.Email), state.NewCodeHash)) + for attempt := 2; attempt <= model.EmailBindingMaxAttempts; attempt++ { + _, err = service.FinishEmailBinding(identity, flow.FlowToken, "invalid", "") + if attempt < model.EmailBindingMaxAttempts { + assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid) + } else { + assert.ErrorIs(t, err, model.ErrEmailBindingLocked) + } + } + _, err = service.FinishEmailBinding(identity, flow.FlowToken, mailbox.code(t, flow.Email), "") + assert.ErrorIs(t, err, model.ErrEmailBindingLocked) +} + +func TestSecurityAccountEmailConcurrentClaimsHaveOneOwner(t *testing.T) { + user, firstIdentity := setupSecurityEnrollmentTest(t) + mailbox := newSecurityMailbox(t) + other := &model.User{Username: "other-user", AffCode: "other-account", Group: "default", Password: user.Password, Status: common.UserStatusEnabled, Role: common.RoleCommonUser, AuthVersion: 1} + require.NoError(t, model.DB.Create(other).Error) + require.NoError(t, model.PublishUserAuthCache(other.Id)) + bundle, err := service.CreateLoginSession(other.Id, "password", "127.0.0.1", "other-user") + require.NoError(t, err) + secondIdentity, err := service.ParseAccessToken(bundle.AccessToken) + require.NoError(t, err) + start, results := make(chan struct{}), make(chan error, 2) + for _, identity := range []service.AuthIdentity{firstIdentity, secondIdentity} { + flow := startSecurityEmailBinding(t, identity, "shared@example.com", service.VerificationMethodPassword) + code := mailbox.code(t, flow.Email) + go func(identity service.AuthIdentity, token, code string) { + <-start + _, err := service.FinishEmailBinding(identity, token, code, "") + results <- err + }(identity, flow.FlowToken, code) + } + close(start) + first, second := <-results, <-results + assert.NotEqual(t, first == nil, second == nil, "only one account may claim the address: %v / %v", first, second) + var owners int64 + require.NoError(t, model.DB.Model(&model.User{}).Where("email = ?", "shared@example.com").Count(&owners).Error) + assert.EqualValues(t, 1, owners) +} + +func TestSecurityAccountEmailRejectsChangedAuthorization(t *testing.T) { + for _, scenario := range []string{"other session", "expired", "email changed", "mfa enabled", "notification failure"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + mailbox := newSecurityMailbox(t) + flow := startSecurityEmailBinding(t, identity, "new@example.com", service.VerificationMethodPassword) + code := mailbox.code(t, flow.Email) + stored, _, err := model.GetEmailBinding(identity, flow.FlowToken) + require.NoError(t, err) + switch scenario { + case "other session": + identity.SessionID = "different-session" + case "expired": + require.NoError(t, model.DB.Model(stored).Update("expires_at", time.Now().Add(-time.Second)).Error) + case "email changed": + require.NoError(t, model.DB.Model(user).Update("email", "changed@example.com").Error) + case "mfa enabled": + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error) + case "notification failure": + common.SMTPServer = "" + } + request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": code}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, scenario == "notification failure", result.Success, response.Body.String()) + if scenario == "notification failure" { + assert.Contains(t, string(result.Data), `"notification_warning":true`) + } + current, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + assert.Equal(t, scenario == "notification failure", current.Email == flow.Email) + }) + } +} + +func TestSecurityAccountOAuthBindingRejectsInvalidFlow(t *testing.T) { + for _, scenario := range []string{"success and replay", "other session", "wrong provider", "expired", "old flow", "mfa enabled"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + oauth.Register("account-oauth", &enrollmentOAuthProvider{externalID: "new-binding"}) + oauth.Register("other-oauth", &enrollmentOAuthProvider{externalID: "wrong-binding"}) + t.Cleanup(func() { oauth.Unregister("account-oauth"); oauth.Unregister("other-oauth") }) + operation := service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"account-oauth"}`)} + proof := issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword) + response := securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"account-oauth","intent":"bind"}`, proof, identity, GenerateOAuthCode) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + var started struct { + FlowToken string `json:"flow_token"` + } + require.NoError(t, common.Unmarshal(result.Data, &started)) + flow, err := model.GetAuthFlow(started.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth}) + require.NoError(t, err) + provider := "account-oauth" + switch scenario { + case "other session": + identity.SessionID = "different-session" + case "wrong provider": + provider = "other-oauth" + case "expired": + require.NoError(t, model.DB.Model(flow).Update("expires_at", time.Now().Add(-time.Second)).Error) + case "old flow": + require.NoError(t, model.DB.Model(flow).Update("payload", "{}").Error) + case "mfa enabled": + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error) + } + handler := func(c *gin.Context) { c.Params = gin.Params{{Key: "provider", Value: provider}}; HandleOAuth(c) } + path := "/api/oauth/" + provider + "?state=" + started.FlowToken + "&code=provider-code" + response = securityEnrollmentRequest("GET", path, "", "", identity, handler) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, scenario == "success and replay", result.Success, response.Body.String()) + current, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + assert.Equal(t, scenario == "success and replay", current.GitHubId == "new-binding") + if result.Success { + replay := securityEnrollmentRequest("GET", path, "", "", identity, handler) + assert.Contains(t, replay.Body.String(), `"success":false`) + } + }) + } +} + +func TestSecurityAccountFirstPasswordRace(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Update("password", "").Error) + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordSet}, service.VerificationMethodPasskey) + request := `{"password":"initial-account-password!42"}` + response := securityEnrollmentRequest("PUT", "/api/user/self", request, proof, identity, UpdateSelf) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, response.Body.String()) + user, identity = setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Update("password", "").Error) + start, results := make(chan struct{}), make(chan error, 2) + for _, password := range []string{"first-password!42", "second-password!42"} { + go func(password string) { + <-start + results <- model.ChangeUserPassword(identity, &model.User{Id: user.Id, Password: password}, true) + }(password) + } + close(start) + first, second := <-results, <-results + assert.NotEqual(t, first == nil, second == nil, "only one concurrent first password may succeed: %v / %v", first, second) + stored, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.True(t, common.ValidatePasswordAndHash("first-password!42", stored.Password) || common.ValidatePasswordAndHash("second-password!42", stored.Password)) +} + +func TestSecurityAccountUnbindPreservesUsableLoginMethod(t *testing.T) { + for _, scenario := range []string{"password", "passkey", "email only", "twofa only", "disabled password", "disabled passkey"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + previousPasswordEnabled := common.PasswordLoginEnabled + t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordEnabled }) + common.PasswordLoginEnabled = scenario != "disabled password" + require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 31, ProviderUserId: "linked-subject"}).Error) + if scenario != "password" && scenario != "disabled password" { + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "email": "verified@example.com"}).Error) + } + if scenario == "passkey" || scenario == "disabled passkey" { + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error) + system_setting.GetPasskeySettings().Enabled = scenario == "passkey" + } + if scenario == "twofa only" { + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error) + } + err := service.UnbindAccountOAuth(identity, 31) + if scenario == "password" || scenario == "passkey" { + require.NoError(t, err) + } else { + assert.ErrorIs(t, err, model.ErrLastLoginMethod) + var binding model.UserOAuthBinding + require.NoError(t, model.DB.Where("user_id = ? AND provider_id = ?", user.Id, 31).First(&binding).Error) + } + }) + } +} diff --git a/controller/security_enrollment_test.go b/controller/security_enrollment_test.go new file mode 100644 index 000000000000..1df088c0db94 --- /dev/null +++ b/controller/security_enrollment_test.go @@ -0,0 +1,1642 @@ +package controller + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/oauth" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/fxamacker/cbor/v2" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentity) { + t.Helper() + require.NoError(t, i18n.Init()) + gin.SetMode(gin.TestMode) + previousDB, previousLogDB := model.DB, model.LOG_DB + previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType() + previousRedis, previousSecret := common.RedisEnabled, common.SessionSecret + previousEncryption := common.PasswordLoginEncryptionEnabled + previousSettings := *system_setting.GetPasskeySettings() + dialect := os.Getenv("TEST_SECURITY_DIALECT") + if dialect == "" { + dialect = "sqlite" + } + dsn := os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN") + db, _ := newAuditTestDatabase(t, dialect, dsn) + logDB, _ := newAuditTestDatabase(t, dialect, dsn) + db.Logger = logger.Default.LogMode(logger.Silent) + logDB.Logger = logger.Default.LogMode(logger.Silent) + versionQuery := "SELECT VERSION()" + if dialect == "sqlite" { + versionQuery = "SELECT sqlite_version()" + } + var version string + require.NoError(t, db.Raw(versionQuery).Scan(&version).Error) + t.Logf("database: %s %s", dialect, version) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{})) + require.NoError(t, logDB.AutoMigrate(&model.AuditLog{})) + model.DB, model.LOG_DB = db, logDB + dbType := common.DatabaseTypeSQLite + if dialect == "mysql" { + dbType = common.DatabaseTypeMySQL + } + if dialect == "postgres" { + dbType = common.DatabaseTypePostgreSQL + } + common.SetDatabaseTypes(dbType, dbType) + common.PasswordLoginEncryptionEnabled = false + common.RedisEnabled = false + common.SessionSecret = "security-enrollment-test-secret" + *system_setting.GetPasskeySettings() = system_setting.PasskeySettings{Enabled: true, RPID: "example.com", Origins: "https://example.com", RPDisplayName: "new-api"} + t.Cleanup(func() { + model.DB, model.LOG_DB = previousDB, previousLogDB + common.SetDatabaseTypes(previousMain, previousLog) + common.RedisEnabled, common.SessionSecret = previousRedis, previousSecret + common.PasswordLoginEncryptionEnabled = previousEncryption + *system_setting.GetPasskeySettings() = previousSettings + connection, err := db.DB() + if err == nil { + _ = connection.Close() + } + }) + password, err := common.Password2Hash("enrollment-password") + require.NoError(t, err) + user := &model.User{Username: "enrollment-user", Password: password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1} + require.NoError(t, db.Create(user).Error) + require.NoError(t, model.PublishUserAuthCache(user.Id)) + bundle, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "enrollment-test") + require.NoError(t, err) + identity, err := service.ParseAccessToken(bundle.AccessToken) + require.NoError(t, err) + return user, identity +} + +func securityEnrollmentRequest(method, path, body, proof string, identity service.AuthIdentity, handler gin.HandlerFunc) *httptest.ResponseRecorder { + response := httptest.NewRecorder() + c, _ := gin.CreateTestContext(response) + c.Request = httptest.NewRequest(method, path, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Request.Header.Set("X-Security-Proof", proof) + c.Set("id", identity.UserID) + c.Set("role", common.RoleCommonUser) + c.Set("session_id", identity.SessionID) + c.Set("auth_version", identity.UserAuthVersion) + c.Set("session_version", identity.SessionVersion) + handler(c) + return response +} + +func issueSecurityEnrollmentProof(t *testing.T, identity service.AuthIdentity, operation service.VerificationOperation, method string) string { + t.Helper() + binding, err := service.BindVerificationOperation(operation) + require.NoError(t, err) + proof, _, err := service.IssueSecurityProof(identity, method, binding) + require.NoError(t, err) + return proof +} + +func TestSecurityEnrollmentAccessTokenRequiresProofBeforeMutation(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.UpdateUserAccessToken(user.Id, "existing-system-token")) + for _, endpoint := range []struct { + method string + handler gin.HandlerFunc + }{ + {"GET", GenerateAccessToken}, + {"POST", GenerateAccessToken}, + {"DELETE", RevokeAccessToken}, + } { + t.Run(endpoint.method, func(t *testing.T) { + response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", "", identity, endpoint.handler) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.Equal(t, http.StatusForbidden, response.Code) + assert.False(t, body.Success) + assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code) + stored, err := model.ValidateAccessToken("existing-system-token") + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, user.Id, stored.Id) + }) + } +} + +func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) { + for _, test := range []struct { + name, method string + password, passkey, twoFA, locked bool + disabledPasskey, oauth, wechat bool + available bool + }{ + {name: "password", method: "password", password: true, oauth: true, available: true}, + {name: "existing passkey", method: "passkey", password: true, passkey: true, available: true}, + {name: "existing twofa", method: "2fa", password: true, passkey: true, twoFA: true, available: true}, + {name: "locked twofa blocks fallback", method: "2fa", password: true, twoFA: true, locked: true}, + {name: "disabled passkey blocks fallback", method: "passkey", password: true, passkey: true, disabledPasskey: true}, + {name: "disabled passkey does not block password", method: "password", password: true, disabledPasskey: true, available: true}, + {name: "linked oauth", method: "oauth", oauth: true, available: true}, + {name: "wechat session cannot manage tokens", method: "oauth", wechat: true}, + } { + t.Run(test.name, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + if !test.password { + require.NoError(t, model.DB.Model(user).Update("password", "").Error) + } + if test.passkey { + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error) + } + if test.twoFA { + twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + if test.locked { + until := time.Now().Add(time.Minute) + twoFA.LockedUntil = &until + } + require.NoError(t, model.DB.Create(twoFA).Error) + } + if test.oauth { + require.NoError(t, model.DB.Model(user).Update("github_id", "linked-user").Error) + oauth.Register("access-token-oauth", &enrollmentOAuthProvider{externalID: "linked-user"}) + t.Cleanup(func() { oauth.Unregister("access-token-oauth") }) + } + if test.wechat { + require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error) + } + system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey + passwordScope := service.VerificationScopePasswordChange + if !test.password { + passwordScope = service.VerificationScopePasswordSet + } + for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenRevoke, + service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, passwordScope} { + requirements, err := service.GetVerificationRequirements(identity, scope) + require.NoError(t, err) + count := 1 + if test.twoFA && test.passkey { + count = 2 + } + require.Len(t, requirements.Methods, count) + assert.Equal(t, test.method, requirements.Methods[0].Method) + assert.Equal(t, test.available, requirements.Methods[0].Available) + if count == 2 { + assert.Equal(t, service.VerificationMethodOption{Method: "passkey", Available: true}, requirements.Methods[1]) + } + if test.wechat { + input := service.VerificationInput{Scope: scope, Method: "session"} + switch scope { + case service.VerificationScopeAccountBind: + input.Context = []byte(`{"provider":"email","email":"new@example.com"}`) + case service.VerificationScopeAccountUnbind: + input.Context = []byte(`{"provider_id":1}`) + } + _, err := service.VerifySecurityInput(identity, input) + assert.ErrorIs(t, err, service.ErrProofMethod) + } + } + }) + } +} + +func TestSecurityEnrollmentAccessTokenLifecycleConsumesProofs(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.UpdateUserAccessToken(user.Id, "previous-token")) + previousToken := "previous-token" + for _, method := range []string{"GET", "POST"} { + proof, err := service.VerifySecurityInput(identity, service.VerificationInput{ + Scope: service.VerificationScopeAccessTokenGenerate, Method: "password", Password: "enrollment-password", + }) + require.NoError(t, err) + wrongScope := securityEnrollmentRequest("DELETE", "/api/user/token", "", proof.ProofToken, identity, RevokeAccessToken) + assert.Contains(t, wrongScope.Body.String(), `"code":"SECURITY_PROOF_SCOPE_MISMATCH"`) + response := securityEnrollmentRequest(method, "/api/user/token", "", proof.ProofToken, identity, GenerateAccessToken) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var token string + require.NoError(t, common.Unmarshal(body.Data, &token)) + assert.GreaterOrEqual(t, len(token), 28) + assert.LessOrEqual(t, len(token), 32) + assert.NotEqual(t, previousToken, token) + stored, err := model.ValidateAccessToken(token) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, user.Id, stored.Id) + assert.Equal(t, model.AccessTokenFingerprint(token), model.AccessTokenFingerprint(stored.GetAccessToken())) + oldUser, err := model.ValidateAccessToken(previousToken) + assert.Nil(t, oldUser) + require.NoError(t, err) + response = securityEnrollmentRequest(method, "/api/user/token", "", proof.ProofToken, identity, GenerateAccessToken) + assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`) + previousToken = token + } + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}, "password") + response := securityEnrollmentRequest("DELETE", "/api/user/token", "", proof, identity, RevokeAccessToken) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + stored, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.Empty(t, stored.GetAccessToken()) + revokedUser, err := model.ValidateAccessToken(previousToken) + require.NoError(t, err) + assert.Nil(t, revokedUser) + response = securityEnrollmentRequest("DELETE", "/api/user/token", "", proof, identity, RevokeAccessToken) + assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`) + response = securityEnrollmentRequest("GET", "/api/user/token/status", "", "", identity, GetAccessTokenStatus) + assert.Contains(t, response.Body.String(), `"exists":false`) + var audits []model.AuditLog + require.NoError(t, model.LOG_DB.Find(&audits).Error) + require.Len(t, audits, 3) + encoded, err := common.Marshal(audits) + require.NoError(t, err) + assert.Contains(t, string(encoded), "access_token.generate") + assert.Contains(t, string(encoded), "access_token.revoke") + assert.NotContains(t, string(encoded), previousToken) + assert.NotContains(t, string(encoded), proof) +} + +func TestSecurityEnrollmentAccessTokenRejectsInvalidProofs(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.UpdateUserAccessToken(user.Id, "unchanged-token")) + for _, endpoint := range []struct { + method, scope string + handler gin.HandlerFunc + }{ + {"GET", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken}, + {"POST", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken}, + {"DELETE", service.VerificationScopeAccessTokenRevoke, RevokeAccessToken}, + } { + for _, failure := range []string{"session", "user", "expired"} { + t.Run(endpoint.method+"/"+failure, func(t *testing.T) { + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope}, "password") + requestIdentity := identity + code := "SECURITY_PROOF_INVALID" + switch failure { + case "session": + requestIdentity.SessionID = "other-session" + case "user": + requestIdentity.UserID++ + case "expired": + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error) + code = "SECURITY_PROOF_EXPIRED" + } + response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, requestIdentity, endpoint.handler) + assert.Equal(t, http.StatusForbidden, response.Code) + assert.Contains(t, response.Body.String(), code) + stored, err := model.ValidateAccessToken("unchanged-token") + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, user.Id, stored.Id) + }) + } + } +} + +func TestSecurityEnrollmentAccessTokenFailureDoesNotRestoreProof(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.UpdateUserAccessToken(user.Id, "unchanged-token")) + require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("access_token_write_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "users" { + tx.AddError(errors.New("private database failure")) + } + })) + for _, endpoint := range []struct { + method, scope string + handler gin.HandlerFunc + }{ + {"POST", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken}, + {"DELETE", service.VerificationScopeAccessTokenRevoke, RevokeAccessToken}, + } { + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope}, "password") + response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, identity, endpoint.handler) + assert.Equal(t, http.StatusInternalServerError, response.Code) + assert.NotContains(t, response.Body.String(), "private database") + response = securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, identity, endpoint.handler) + assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`) + } + stored, err := model.ValidateAccessToken("unchanged-token") + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, user.Id, stored.Id) +} + +func authorizeSecurityEnrollment(t *testing.T, identity service.AuthIdentity) *model.AuthFlowAuthorization { + t.Helper() + operation := service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup} + proof := issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword) + authorization, err := service.ConsumeOperationProof(proof, identity, operation) + require.NoError(t, err) + return authorization +} + +// securityPasskeyResponse acts as a software authenticator at the browser boundary. +// The handlers still validate the real WebAuthn challenge, origin and signature. +func securityPasskeyResponse(t *testing.T, key *ecdsa.PrivateKey, challenge string, registration bool, counter uint32, userVerified ...bool) json.RawMessage { + t.Helper() + ceremony := "webauthn.get" + if registration { + ceremony = "webauthn.create" + } + clientData, err := common.Marshal(map[string]any{"type": ceremony, "challenge": challenge, "origin": "https://example.com"}) + require.NoError(t, err) + credentialID := sha256.Sum256(elliptic.Marshal(key.Curve, key.X, key.Y)) + rpIDHash := sha256.Sum256([]byte("example.com")) + authData := append([]byte{}, rpIDHash[:]...) + response := map[string]any{"clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData)} + if registration { + flags := byte(0x45) // user present, user verified, attested credential + if len(userVerified) > 0 && !userVerified[0] { + flags = 0x41 + } + authData = append(authData, flags) + authData = append(authData, make([]byte, 4+16)...) + authData = binary.BigEndian.AppendUint16(authData, uint16(len(credentialID))) + authData = append(authData, credentialID[:]...) + publicKey, err := cbor.Marshal(map[int]any{ + 1: 2, 3: -7, -1: 1, -2: key.X.FillBytes(make([]byte, 32)), -3: key.Y.FillBytes(make([]byte, 32)), + }) + require.NoError(t, err) + authData = append(authData, publicKey...) + attestation, err := cbor.Marshal(map[string]any{"fmt": "none", "authData": authData, "attStmt": map[string]any{}}) + require.NoError(t, err) + response["attestationObject"] = base64.RawURLEncoding.EncodeToString(attestation) + } else { + flags := byte(0x05) // user present and verified + if len(userVerified) > 0 && !userVerified[0] { + flags = 0x01 + } + authData = append(authData, flags) + authData = binary.BigEndian.AppendUint32(authData, counter) + clientHash := sha256.Sum256(clientData) + signedData := append(append([]byte{}, authData...), clientHash[:]...) + signedHash := sha256.Sum256(signedData) + signature, err := ecdsa.SignASN1(rand.Reader, key, signedHash[:]) + require.NoError(t, err) + response["authenticatorData"] = base64.RawURLEncoding.EncodeToString(authData) + response["signature"] = base64.RawURLEncoding.EncodeToString(signature) + } + id := base64.RawURLEncoding.EncodeToString(credentialID[:]) + credential, err := common.Marshal(map[string]any{"id": id, "rawId": id, "type": "public-key", "response": response}) + require.NoError(t, err) + return credential +} + +func TestSecurityEnrollmentRejectsMissingProofBeforeCreatingCredentials(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + for _, test := range []struct { + path string + handler gin.HandlerFunc + }{ + {"/api/user/2fa/setup", Setup2FA}, + {"/api/user/passkey/register/begin", PasskeyRegisterBegin}, + } { + t.Run(test.path, func(t *testing.T) { + response := securityEnrollmentRequest(http.MethodPost, test.path, `{}`, "", identity, test.handler) + assert.Equal(t, http.StatusForbidden, response.Code) + assert.Contains(t, response.Body.String(), "SECURITY_PROOF_REQUIRED") + assert.NotContains(t, response.Body.String(), "qr_code_data") + }) + } + pending, err := model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + assert.Nil(t, pending) +} + +type securityEnrollmentResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Code string `json:"code"` + Data json.RawMessage `json:"data"` +} + +func TestSecurityEnrollmentMethodPolicy(t *testing.T) { + for _, test := range []struct { + name string + password, passkey, twoFA, locked, disabledPasskey bool + method string + available bool + }{ + {name: "first factor uses password", password: true, method: "password", available: true}, + {name: "existing passkey takes precedence", password: true, passkey: true, method: "passkey", available: true}, + {name: "twofa and passkey are alternatives", password: true, passkey: true, twoFA: true, method: "2fa", available: true}, + {name: "locked twofa permits passkey", password: true, passkey: true, twoFA: true, locked: true, method: "2fa"}, + {name: "disabled passkey does not fall back", password: true, passkey: true, disabledPasskey: true, method: "passkey"}, + {name: "disabled registration does not request a password", password: true, disabledPasskey: true, method: "password"}, + {name: "passwordless account without providers is unavailable", method: "oauth"}, + } { + t.Run(test.name, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + if !test.password { + require.NoError(t, model.DB.Model(user).Update("password", "").Error) + } + if test.passkey { + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "enrolled-passkey", PublicKey: "public-key"}).Error) + } + if test.twoFA { + twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + if test.locked { + until := time.Now().Add(time.Minute) + twoFA.LockedUntil = &until + } + require.NoError(t, model.DB.Create(twoFA).Error) + } + system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey + requirements, err := service.GetVerificationRequirements(identity, "passkey.register") + require.NoError(t, err) + count := 1 + if test.twoFA && test.passkey { + count = 2 + } + require.Len(t, requirements.Methods, count) + assert.Equal(t, test.method, requirements.Methods[0].Method) + assert.Equal(t, test.available, requirements.Methods[0].Available) + if count == 2 { + assert.Equal(t, service.VerificationMethodOption{Method: "passkey", Available: true}, requirements.Methods[1]) + } + if test.passkey && !test.twoFA { + requirements, err = service.GetVerificationRequirements(identity, "2fa.setup") + require.NoError(t, err) + assert.Equal(t, "passkey", requirements.Methods[0].Method) + } + }) + } +} + +func TestSecurityEnrollmentPasswordProofIsBoundToSessionAndAction(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + response := securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"wrong"}`, "", identity, UniversalVerify) + assert.NotContains(t, response.Body.String(), "proof_token") + response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "", identity, UniversalVerify) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var proof service.SecurityProof + require.NoError(t, common.Unmarshal(body.Data, &proof)) + _, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"}) + assert.ErrorIs(t, err, service.ErrProofScope) + other := identity + other.SessionID = "another-session" + _, err = service.ConsumeOperationProof(proof.ProofToken, other, service.VerificationOperation{Scope: "2fa.setup"}) + assert.ErrorIs(t, err, service.ErrAuthTokenInvalid) + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"}) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"channel.key.read","password":"enrollment-password"}`, "", identity, UniversalVerify) + assert.NotContains(t, response.Body.String(), "proof_token") + common.PasswordLoginEncryptionEnabled = true + response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "", identity, UniversalVerify) + assert.NotContains(t, response.Body.String(), "proof_token", "encryption-required mode must reject plaintext") + common.PasswordLoginEncryptionEnabled = false + require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error) + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"}) + assert.ErrorIs(t, err, service.ErrLoginSessionRevoked) +} + +func TestSecurityEnrollmentProofStartsOnlyOneSetup(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "2fa.setup"}, "password") + first := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(first.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + second := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA) + assert.Equal(t, http.StatusForbidden, second.Code) + require.NoError(t, common.Unmarshal(second.Body.Bytes(), &body)) + assert.False(t, body.Success) + assert.Equal(t, "SECURITY_PROOF_CONSUMED", body.Code) +} + +func TestSecurityEnrollmentOperationContext(t *testing.T) { + for _, test := range []struct { + name, scope, context string + err error + }{ + {"channel", "channel.key.read", `{"channel_id":123}`, nil}, + {"missing channel", "channel.key.read", ``, service.ErrVerificationContextInvalid}, + {"null channel", "channel.key.read", `{"channel_id":null}`, service.ErrVerificationContextInvalid}, + {"string channel", "channel.key.read", `{"channel_id":"123"}`, service.ErrVerificationContextInvalid}, + {"fractional channel", "channel.key.read", `{"channel_id":123.5}`, service.ErrVerificationContextInvalid}, + {"zero channel", "channel.key.read", `{"channel_id":0}`, service.ErrVerificationContextInvalid}, + {"negative channel", "channel.key.read", `{"channel_id":-1}`, service.ErrVerificationContextInvalid}, + {"overflow channel", "channel.key.read", `{"channel_id":18446744073709551615}`, service.ErrVerificationContextInvalid}, + {"extra field", "channel.key.read", `{"channel_id":123,"extra":true}`, service.ErrVerificationContextInvalid}, + {"null context", "passkey.register", `null`, service.ErrVerificationContextInvalid}, + {"array context", "passkey.register", `[]`, service.ErrVerificationContextInvalid}, + {"empty enrollment", "passkey.register", `{}`, nil}, + {"implicit enrollment", "passkey.register", ``, nil}, + {"generate access token", "access_token.generate", `{}`, nil}, + {"revoke access token", "access_token.revoke", ``, nil}, + {"access token target injection", "access_token.revoke", `{"user_id":42}`, service.ErrVerificationContextInvalid}, + {"enrollment target injection", "passkey.register", `{"user_id":42}`, service.ErrVerificationContextInvalid}, + {"unknown scope", "user.email.change", `{}`, service.ErrProofScope}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := service.BindVerificationOperation(service.VerificationOperation{Scope: test.scope, Context: []byte(test.context)}) + assert.ErrorIs(t, err, test.err) + }) + } + var first, reordered service.VerificationOperation + require.NoError(t, common.UnmarshalJsonStr(`{"scope":"channel.key.read","context":{"channel_id":123}}`, &first)) + require.NoError(t, common.UnmarshalJsonStr(`{"context": { "channel_id": 123 }, "scope":"channel.key.read"}`, &reordered)) + firstBinding, err := service.BindVerificationOperation(first) + require.NoError(t, err) + secondBinding, err := service.BindVerificationOperation(reordered) + require.NoError(t, err) + assert.Equal(t, firstBinding, secondBinding) +} + +func TestSecurityEnrollmentChannelProofRejectsMismatchesBeforeConsumption(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error) + require.NoError(t, model.PublishUserAuthCache(user.Id)) + twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + require.NoError(t, model.DB.Create(twoFA).Error) + operation := service.VerificationOperation{Scope: service.VerificationScopeChannelKeyRead, Context: []byte(`{"channel_id":123}`)} + code, err := totp.GenerateCode(twoFA.Secret, time.Now()) + require.NoError(t, err) + proof, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "2fa", Scope: operation.Scope, Context: operation.Context, Code: code}) + require.NoError(t, err) + for _, test := range []struct { + name string + identity service.AuthIdentity + operation service.VerificationOperation + err error + }{ + {"channel", identity, service.VerificationOperation{Scope: operation.Scope, Context: []byte(`{"channel_id":456}`)}, service.ErrProofContext}, + {"scope", identity, service.VerificationOperation{Scope: "passkey.delete"}, service.ErrProofScope}, + {"user", service.AuthIdentity{UserID: user.Id + 1, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid}, + {"session", service.AuthIdentity{UserID: user.Id, SessionID: "other-session", UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid}, + {"session version", service.AuthIdentity{UserID: user.Id, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion + 1}, operation, service.ErrAuthTokenInvalid}, + {"user version", service.AuthIdentity{UserID: user.Id, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion + 1, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := service.ConsumeOperationProof(proof.ProofToken, test.identity, test.operation) + assert.ErrorIs(t, err, test.err) + }) + } + require.NoError(t, model.DB.Model(twoFA).Update("is_enabled", false).Error) + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, operation) + assert.ErrorIs(t, err, service.ErrProofMethod) + require.NoError(t, model.DB.Model(twoFA).Update("is_enabled", true).Error) + authorization, err := service.ConsumeOperationProof(proof.ProofToken, identity, operation) + require.NoError(t, err) + assert.Positive(t, authorization.ProofID) + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, operation) + assert.ErrorIs(t, err, service.ErrProofConsumed) +} + +func TestSecurityEnrollmentProofConcurrentConsumption(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + operation := service.VerificationOperation{Scope: "passkey.register"} + proof := issueSecurityEnrollmentProof(t, identity, operation, "password") + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func() { + <-start + _, err := service.ConsumeOperationProof(proof, identity, operation) + results <- err + }() + } + close(start) + successes := 0 + for range 2 { + if err := <-results; err != nil { + assert.ErrorIs(t, err, service.ErrProofConsumed) + } else { + successes++ + } + } + assert.Equal(t, 1, successes) +} + +func TestSecurityEnrollmentProofRequiresLiveRecordAndExactDeadline(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + operation := service.VerificationOperation{Scope: "passkey.register"} + proof := issueSecurityEnrollmentProof(t, identity, operation, "password") + claims := jwt.MapClaims{} + _, _, err := jwt.NewParser().ParseUnverified(proof, claims) + require.NoError(t, err) + assert.Equal(t, float64(60), claims["exp"].(float64)-claims["iat"].(float64)) + assert.NotEmpty(t, claims["context_hash"]) + assert.NotContains(t, claims, "context") + assert.NotContains(t, claims, "channel_id") + var stored model.AuthFlow + require.NoError(t, model.DB.Where("purpose = ?", model.AuthFlowPurposeSecurityProof).First(&stored).Error) + assert.Equal(t, int64(claims["exp"].(float64)), stored.ExpiresAt.Unix()) + assert.NotEqual(t, proof, stored.TokenHash) + assert.NotEqual(t, claims["jti"], stored.TokenHash) + require.NoError(t, model.DB.Model(&stored).Update("expires_at", time.Now()).Error) + _, err = service.ConsumeOperationProof(proof, identity, operation) + assert.ErrorIs(t, err, service.ErrAuthTokenExpired, "database deadline must reject even while the JWT is within its clock tolerance") + require.NoError(t, model.DB.Delete(&stored).Error) + _, err = service.ConsumeOperationProof(proof, identity, operation) + assert.ErrorIs(t, err, service.ErrAuthTokenInvalid) +} + +func TestSecurityEnrollmentProofIsBurnedAfterBusinessFailure(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password") + require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_flow_creation_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "auth_flows" { + tx.AddError(errors.New("injected creation failure")) + } + })) + response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin) + assert.Equal(t, http.StatusInternalServerError, response.Code) + require.NoError(t, model.DB.Callback().Create().Remove("security_flow_creation_failure")) + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin) + assert.Equal(t, http.StatusForbidden, response.Code) + assert.Contains(t, response.Body.String(), "SECURITY_PROOF_CONSUMED") +} + +func TestSecurityEnrollmentProofStorageErrorsFailClosed(t *testing.T) { + for _, stage := range []string{"issuance", "consumption"} { + t.Run(stage, func(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + proof := "" + if stage == "consumption" { + proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password") + } + failure := func(tx *gorm.DB) { + if tx.Statement.Table == "auth_flows" { + tx.AddError(errors.New("private proof database failure")) + } + } + if stage == "issuance" { + require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("proof_storage_failure", failure)) + } else { + require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("proof_storage_failure", failure)) + } + var response *httptest.ResponseRecorder + if stage == "issuance" { + response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"passkey.register","password":"enrollment-password"}`, "", identity, UniversalVerify) + require.NoError(t, model.DB.Callback().Create().Remove("proof_storage_failure")) + } else { + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin) + require.NoError(t, model.DB.Callback().Update().Remove("proof_storage_failure")) + } + assert.Equal(t, http.StatusInternalServerError, response.Code) + assert.Contains(t, response.Body.String(), "AUTH_INTERNAL_ERROR") + assert.NotContains(t, response.Body.String(), "private") + assert.NotContains(t, response.Body.String(), "proof_token") + assert.NotContains(t, response.Body.String(), "flow_token") + var count int64 + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Count(&count).Error) + assert.Zero(t, count) + if stage == "consumption" { + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.True(t, result.Success, "a failed consumption transaction must not burn the proof") + } + }) + } +} + +func TestSecurityEnrollmentVerificationTransportsRejectInvalidContext(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"}) + t.Cleanup(func() { oauth.Unregister("enrollment-oauth") }) + for _, test := range []struct { + path, body string + handler gin.HandlerFunc + }{ + {"/api/verify", `{"method":"password","scope":"passkey.register","context":{"user_id":999},"password":"enrollment-password"}`, UniversalVerify}, + {"/api/user/passkey/verify/begin", `{"scope":"passkey.register","context":{"user_id":999}}`, PasskeyVerifyBegin}, + {"/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"passkey.register","context":{"user_id":999}}`, GenerateOAuthCode}, + } { + t.Run(test.path, func(t *testing.T) { + response := securityEnrollmentRequest("POST", test.path, test.body, "", identity, test.handler) + assert.Equal(t, http.StatusBadRequest, response.Code) + assert.Contains(t, response.Body.String(), "SECURITY_CONTEXT_INVALID") + }) + } + var count int64 + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&count).Error) + assert.Zero(t, count) +} + +func TestSecurityEnrollmentPendingPasskeyRejectsChangedAuthorization(t *testing.T) { + for _, change := range []string{"expired", "revoked", "session version", "user version", "method", "other session"} { + t.Run(change, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password") + response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.True(t, result.Success, result.Message) + var begin struct { + FlowToken string `json:"flow_token"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + } `json:"publicKey"` + } `json:"options"` + } + require.NoError(t, common.Unmarshal(result.Data, &begin)) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + body, err := common.Marshal(passkeyFinishRequest{ + FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0), + }) + require.NoError(t, err) + switch change { + case "expired": + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Update("expires_at", time.Now().Add(-time.Minute)).Error) + case "revoked": + _, err = model.RevokeUserSession(user.Id, identity.SessionID, "security-test") + require.NoError(t, err) + case "session version": + require.NoError(t, model.DB.Model(&model.UserSession{}).Where("sid = ?", identity.SessionID).Update("version", 2).Error) + case "user version": + require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error) + case "method": + require.NoError(t, model.DB.Model(user).Update("password", "").Error) + case "other session": + other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-session") + require.NoError(t, err) + identity, err = service.ParseAccessToken(other.AccessToken) + require.NoError(t, err) + } + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.False(t, result.Success) + _, err = model.GetPasskeyByUserID(user.Id) + assert.ErrorIs(t, err, model.ErrPasskeyNotFound) + }) + } +} + +func TestSecurityEnrollmentPasskeyProofProtectsChannelKeyRead(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error) + require.NoError(t, model.PublishUserAuthCache(user.Id)) + require.NoError(t, model.DB.AutoMigrate(&model.Channel{})) + for _, channel := range []model.Channel{ + {Id: 123, Name: "first", Key: "first-channel-secret", Type: 1, Status: 1}, + {Id: 456, Name: "second", Key: "second-channel-secret", Type: 1, Status: 1}, + } { + require.NoError(t, model.DB.Create(&channel).Error) + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + registrationProof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password") + response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", registrationProof, identity, PasskeyRegisterBegin) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var begin struct { + FlowToken string `json:"flow_token"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + } `json:"publicKey"` + } `json:"options"` + } + require.NoError(t, common.Unmarshal(body.Data, &begin)) + require.NotEmpty(t, begin.Options.PublicKey.Challenge) + registrationBody, err := common.Marshal(passkeyFinishRequest{ + FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0), + }) + require.NoError(t, err) + // The dedicated registration flow remains authorized after the consumed proof expires. + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error) + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(registrationBody), "", identity, PasskeyRegisterFinish) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var rotation struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, common.Unmarshal(body.Data, &rotation)) + identity, err = service.ParseAccessToken(rotation.AccessToken) + require.NoError(t, err) + assert.EqualValues(t, 2, identity.UserAuthVersion) + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(registrationBody), "", identity, PasskeyRegisterFinish) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.False(t, body.Success) + + response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", `{"scope":"channel.key.read","context":{"channel_id":123}}`, "", identity, PasskeyVerifyBegin) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + require.NoError(t, common.Unmarshal(body.Data, &begin)) + assertionBody, err := common.Marshal(map[string]any{ + "flow_token": begin.FlowToken, "credential": securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, false, 1), + "scope": "passkey.delete", "context": map[string]any{"channel_id": 456}, + }) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(assertionBody), "", identity, PasskeyVerifyFinish) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var proof service.SecurityProof + require.NoError(t, common.Unmarshal(body.Data, &proof)) + assert.Equal(t, "passkey", proof.Method) + assert.Equal(t, "channel.key.read", proof.Scope, "finish cannot replace the operation approved at begin") + + router := gin.New() + router.POST("/api/channel/:id/key", middleware.RootAuth(), middleware.SecureVerificationRequired(), GetChannelKey) + for _, test := range []struct { + name, path, proof, code, key string + status int + }{ + {"login token only", "/api/channel/123/key", "", "SECURITY_PROOF_REQUIRED", "", http.StatusForbidden}, + {"access token as proof", "/api/channel/123/key", rotation.AccessToken, "SECURITY_PROOF_INVALID", "", http.StatusForbidden}, + {"other channel", "/api/channel/456/key", proof.ProofToken, "SECURITY_PROOF_CONTEXT_MISMATCH", "", http.StatusForbidden}, + {"authorized channel", "/api/channel/123/key", proof.ProofToken, "", "first-channel-secret", http.StatusOK}, + {"replay", "/api/channel/123/key", proof.ProofToken, "SECURITY_PROOF_CONSUMED", "", http.StatusForbidden}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest("POST", test.path, nil) + request.Header.Set("Authorization", "Bearer "+rotation.AccessToken) + request.Header.Set("X-Security-Proof", test.proof) + result := httptest.NewRecorder() + router.ServeHTTP(result, request) + assert.Equal(t, test.status, result.Code) + var response securityEnrollmentResponse + require.NoError(t, common.Unmarshal(result.Body.Bytes(), &response)) + assert.Equal(t, test.code, response.Code) + if test.key != "" { + assert.Contains(t, string(response.Data), test.key) + } else { + assert.NotContains(t, result.Body.String(), "channel-secret") + } + }) + } + var logs []model.AuditLog + require.NoError(t, model.LOG_DB.Where("action = ?", "channel.key_view").Find(&logs).Error) + require.Len(t, logs, 1) + encodedLogs, err := common.Marshal(logs) + require.NoError(t, err) + assert.NotContains(t, string(encodedLogs), "first-channel-secret") + assert.NotContains(t, string(encodedLogs), proof.ProofToken) + + response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", `{"scope":"passkey.delete"}`, "", identity, PasskeyVerifyBegin) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + require.NoError(t, common.Unmarshal(body.Data, &begin)) + deleteAssertion, err := common.Marshal(passkeyFinishRequest{ + FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, false, 2), + }) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(deleteAssertion), "", identity, PasskeyVerifyFinish) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + require.NoError(t, common.Unmarshal(body.Data, &proof)) + response = securityEnrollmentRequest("DELETE", "/api/user/passkey", "", proof.ProofToken, identity, PasskeyDelete) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + _, err = model.GetPasskeyByUserID(user.Id) + assert.ErrorIs(t, err, model.ErrPasskeyNotFound) +} + +func TestSecurityEnrollmentVerifyRequiresDedicatedFlowForInteractiveMethods(t *testing.T) { + for _, method := range []string{service.VerificationMethodPasskey, service.VerificationMethodOAuth} { + t.Run(method, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + if method == service.VerificationMethodPasskey { + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "enrolled-passkey", PublicKey: "public-key"}).Error) + } else { + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error) + oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"}) + t.Cleanup(func() { oauth.Unregister("enrollment-oauth") }) + } + _, err := service.RequireVerificationMethod(identity, service.VerificationScopeTwoFASetup, method) + require.NoError(t, err) + payload, err := common.Marshal(service.VerificationInput{Method: method, Scope: service.VerificationScopeTwoFASetup}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/verify", string(payload), "", identity, UniversalVerify) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.Equal(t, http.StatusBadRequest, response.Code) + assert.False(t, body.Success) + assert.Equal(t, "SECURITY_VERIFICATION_FLOW_REQUIRED", body.Code) + assert.Equal(t, "This verification method requires its dedicated verification flow.", body.Message) + assert.Empty(t, body.Data) + }) + } +} + +func TestSecurityEnrollmentTwoFAFlowAndSessionRotation(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-device") + require.NoError(t, err) + var proof string + var setups []service.TwoFASetup + for range 2 { + proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "2fa.setup"}, "password") + response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var setup service.TwoFASetup + require.NoError(t, common.Unmarshal(body.Data, &setup)) + require.NotEmpty(t, setup.FlowToken) + require.Len(t, setup.BackupCodes, common.BackupCodeCount) + setups = append(setups, setup) + } + oldCode, err := totp.GenerateCode(setups[0].Secret, time.Now()) + require.NoError(t, err) + assert.ErrorIs(t, service.FinishTwoFASetup(identity, setups[0].FlowToken, oldCode), model.ErrTwoFASetupInvalid) + assert.ErrorIs(t, service.FinishTwoFASetup(identity, setups[1].FlowToken, "not-a-code"), model.ErrTwoFACodeInvalid) + _, err = model.GetAuthFlow(setups[1].FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup}) + require.NoError(t, err, "invalid code must not consume setup") + code, err := totp.GenerateCode(setups[1].Secret, time.Now()) + require.NoError(t, err) + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error) + payload, err := common.Marshal(Verify2FARequest{Code: code, FlowToken: setups[1].FlowToken}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(payload), "", identity, Enable2FA) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var rotated struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, common.Unmarshal(body.Data, &rotated)) + newIdentity, err := service.ParseAccessToken(rotated.AccessToken) + require.NoError(t, err) + assert.EqualValues(t, 2, newIdentity.UserAuthVersion) + assert.Equal(t, identity.SessionID, newIdentity.SessionID) + otherSession, err := model.GetUserSessionBySID(other.Session.SID) + require.NoError(t, err) + assert.Equal(t, model.UserSessionStatusRevoked, otherSession.Status) + _, err = model.GetAuthFlow(setups[1].FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup}) + assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) + _, err = service.ConsumeOperationProof(proof, newIdentity, service.VerificationOperation{Scope: "2fa.setup"}) + assert.Error(t, err) + enabled, err := model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + assert.True(t, enabled.IsEnabled) + var logs []model.AuditLog + require.NoError(t, model.LOG_DB.Find(&logs).Error) + require.NotEmpty(t, logs) + encoded, err := common.Marshal(logs) + require.NoError(t, err) + for _, secret := range []string{setups[0].Secret, setups[1].Secret, proof, code, setups[1].FlowToken} { + assert.NotContains(t, string(encoded), secret) + } +} + +func TestSecurityEnrollmentSetupAndEnableRollback(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + setup, err := service.StartTwoFASetup(identity, authorizeSecurityEnrollment(t, identity)) + require.NoError(t, err) + failure := errors.New("injected storage failure") + authorization := authorizeSecurityEnrollment(t, identity) + require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_setup_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "auth_flows" { + tx.AddError(failure) + } + })) + _, err = service.StartTwoFASetup(identity, authorization) + assert.ErrorIs(t, err, failure) + require.NoError(t, model.DB.Callback().Create().Remove("security_setup_failure")) + pending, err := model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + assert.Equal(t, setup.Secret, pending.Secret) + count, err := model.GetUnusedBackupCodeCount(user.Id) + require.NoError(t, err) + assert.Equal(t, common.BackupCodeCount, count) + code, err := totp.GenerateCode(setup.Secret, time.Now()) + require.NoError(t, err) + require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("security_enable_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "two_fas" { + tx.AddError(failure) + } + })) + assert.ErrorIs(t, service.FinishTwoFASetup(identity, setup.FlowToken, code), failure) + require.NoError(t, model.DB.Callback().Update().Remove("security_enable_failure")) + _, err = model.GetAuthFlow(setup.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup}) + require.NoError(t, err) + storedUser, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + assert.Equal(t, identity.UserAuthVersion, storedUser.AuthVersion) + require.NoError(t, service.FinishTwoFASetup(identity, setup.FlowToken, code)) +} + +type enrollmentOAuthProvider struct { + authFlowTestOAuthProvider + externalID string + disabled bool +} + +func (*enrollmentOAuthProvider) ProviderUserIDColumn() string { return "github_id" } +func (p *enrollmentOAuthProvider) IsEnabled() bool { return !p.disabled } +func (p *enrollmentOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) { + return &oauth.OAuthUser{ProviderUserID: p.externalID, Email: "same@example.com"}, nil +} + +func TestSecurityEnrollmentOAuthVerificationNeverChangesLoginOrBindings(t *testing.T) { + for _, scenario := range []string{"success", "different account", "binding changed", "session changed", "auth version changed", "provider disabled", "cancelled"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user", "email": "same@example.com"}).Error) + provider := &enrollmentOAuthProvider{externalID: "linked-user"} + oauth.Register("enrollment-oauth", provider) + t.Cleanup(func() { oauth.Unregister("enrollment-oauth") }) + response := securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"2fa.setup"}`, "", identity, GenerateOAuthCode) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var started struct { + FlowToken string `json:"flow_token"` + } + require.NoError(t, common.Unmarshal(body.Data, &started)) + callbackIdentity := identity + query := "&code=authorization-code" + switch scenario { + case "different account": + provider.externalID = "other-user" + case "binding changed": + require.NoError(t, model.DB.Model(user).Update("github_id", "replacement-user").Error) + case "session changed": + callbackIdentity.SessionID = "different-session" + case "auth version changed": + require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error) + case "cancelled": + query = "&error=access_denied" + case "provider disabled": + provider.disabled = true + } + handler := func(c *gin.Context) { + c.Params = gin.Params{{Key: "provider", Value: "enrollment-oauth"}} + HandleOAuth(c) + } + path := "/api/oauth/enrollment-oauth?state=" + started.FlowToken + query + response = securityEnrollmentRequest("GET", path, "", "", callbackIdentity, handler) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.Equal(t, scenario == "success", body.Success, body.Message) + assert.NotContains(t, response.Body.String(), "access_token") + assert.Empty(t, response.Header().Values("Set-Cookie")) + if scenario == "success" { + var proof service.SecurityProof + require.NoError(t, common.Unmarshal(body.Data, &proof)) + _, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"}) + require.NoError(t, err) + replayed := securityEnrollmentRequest("GET", path, "", "", identity, handler) + assert.Equal(t, http.StatusForbidden, replayed.Code) + } + var users, sessions, bindings int64 + require.NoError(t, model.DB.Model(&model.User{}).Count(&users).Error) + require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&sessions).Error) + require.NoError(t, model.DB.Model(&model.UserOAuthBinding{}).Count(&bindings).Error) + assert.EqualValues(t, 1, users) + assert.EqualValues(t, 1, sessions) + assert.Zero(t, bindings) + stored, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + expectedBinding := "linked-user" + if scenario == "binding changed" { + expectedBinding = "replacement-user" + } + assert.Equal(t, expectedBinding, stored.GitHubId) + }) + } +} + +func TestSecurityEnrollmentEncryptedPasswordVerification(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + keyID, publicPEM := common.PasswordEncryptionPublicKey() + if keyID == "" { + privatePEM, err := common.GeneratePasswordEncryptionPrivateKey() + require.NoError(t, err) + require.NoError(t, common.LoadPasswordEncryptionPrivateKey(privatePEM)) + keyID, publicPEM = common.PasswordEncryptionPublicKey() + } + block, _ := pem.Decode([]byte(publicPEM)) + require.NotNil(t, block) + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + require.NoError(t, err) + publicKey, ok := parsed.(*rsa.PublicKey) + require.True(t, ok) + ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, []byte("enrollment-password"), nil) + require.NoError(t, err) + common.PasswordLoginEncryptionEnabled = true + input := service.VerificationInput{Method: "password", Scope: "passkey.register", PasswordEncrypted: base64.StdEncoding.EncodeToString(ciphertext), EncryptionKeyID: keyID} + proof, err := service.VerifySecurityInput(identity, input) + require.NoError(t, err) + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: input.Scope}) + require.NoError(t, err) + input.EncryptionKeyID = "incorrect-key-id" + _, err = service.VerifySecurityInput(identity, input) + assert.ErrorIs(t, err, service.ErrVerificationFailed) +} + +func TestSecurityEnrollmentTwoFAFailureAccountingAndStorageErrors(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + require.NoError(t, model.DB.Create(twoFA).Error) + for _, endpoint := range []struct { + path string + handler gin.HandlerFunc + }{ + {"/api/user/2fa/disable", Disable2FA}, + {"/api/user/2fa/backup_codes", RegenerateBackupCodes}, + {"/api/user/login/2fa", Verify2FALogin}, + } { + response := securityEnrollmentRequest("POST", endpoint.path, `{}`, "", identity, endpoint.handler) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.False(t, body.Success, endpoint.path) + if endpoint.path == "/api/user/login/2fa" { + assert.Equal(t, "参数错误", body.Message, endpoint.path) + } else { + assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code, endpoint.path) + } + } + stored, err := model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + assert.Zero(t, stored.FailedAttempts, "missing required input must not count as a failed verification") + wrongCode := "" + for _, candidate := range []string{"000000", "111111", "222222", "333333"} { + if !common.ValidateTOTPCode(twoFA.Secret, candidate) { + wrongCode = candidate + break + } + } + require.NotEmpty(t, wrongCode) + assert.ErrorIs(t, service.VerifyTwoFactorCode(twoFA, wrongCode), service.ErrVerificationFailed) + stored, err = model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + assert.Equal(t, 1, stored.FailedAttempts) + hash, err := common.HashBackupCode("ABCD-1234") + require.NoError(t, err) + require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error) + require.NoError(t, service.VerifyTwoFactorCode(stored, "ABCD-1234")) + assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, "ABCD-1234"), service.ErrVerificationFailed) + until := time.Now().Add(time.Minute) + stored.LockedUntil = &until + assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, "ABCD-1234"), service.ErrVerificationLocked) + stored.LockedUntil = nil + code, err := totp.GenerateCode(stored.Secret, time.Now()) + require.NoError(t, err) + failure := errors.New("usage storage failed") + require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("security_usage_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "two_fas" { + tx.AddError(failure) + } + })) + assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, code), failure) + require.NoError(t, model.DB.Callback().Update().Remove("security_usage_failure")) +} + +func TestSecurityEnrollmentDatabaseErrorsAreNotReturned(t *testing.T) { + for _, test := range []struct { + name, method, path, body, table string + create bool + handler gin.HandlerFunc + }{ + {"methods", "GET", "/api/verify/methods?scope=2fa.setup", "", "users", false, GetVerificationMethods}, + {"password", "POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "users", false, UniversalVerify}, + {"passkey", "POST", "/api/user/passkey/register/begin", "", "users", false, PasskeyRegisterBegin}, + {"2fa status", "GET", "/api/user/2fa/status", "", "two_fas", false, Get2FAStatus}, + {"2fa setup", "POST", "/api/user/2fa/setup", "", "two_fas", true, Setup2FA}, + {"2fa flow", "POST", "/api/user/2fa/setup", "", "auth_flows", true, Setup2FA}, + {"oauth state", "POST", "/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"2fa.setup"}`, "users", false, GenerateOAuthCode}, + {"channel key", "POST", "/api/channel/1/key", "", "channels", false, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: "1"}} + GetChannelKey(c) + }}, + } { + t.Run(test.name, func(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"}) + t.Cleanup(func() { oauth.Unregister("enrollment-oauth") }) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup}, service.VerificationMethodPassword) + privateError := errors.New("database connection failed: private-db-host private_table SELECT secret_column") + callback := func(tx *gorm.DB) { + if tx.Statement.Table == test.table { + tx.AddError(privateError) + } + } + if test.create { + require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_private_error", callback)) + } else { + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("security_private_error", callback)) + } + response := securityEnrollmentRequest(test.method, test.path, test.body, proof, identity, test.handler) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.Equal(t, http.StatusInternalServerError, response.Code) + assert.False(t, body.Success) + assert.Equal(t, "AUTH_INTERNAL_ERROR", body.Code) + assert.Equal(t, "Internal Server Error", body.Message) + assert.NotContains(t, response.Body.String(), "private") + assert.NotContains(t, response.Body.String(), "SELECT") + }) + } +} + +func TestSecurityEnrollmentPublicErrorsDiscardWrappedDetails(t *testing.T) { + for _, test := range []struct { + err error + code string + message string + }{ + {service.ErrVerificationFailed, "SECURITY_VERIFICATION_FAILED", service.ErrVerificationFailed.Error()}, + {service.ErrVerificationLocked, "SECURITY_VERIFICATION_LOCKED", service.ErrVerificationLocked.Error()}, + {service.ErrOAuthAccountMismatch, "OAUTH_ACCOUNT_MISMATCH", service.ErrOAuthAccountMismatch.Error()}, + {model.ErrTwoFASetupInvalid, "TWOFA_SETUP_INVALID", model.ErrTwoFASetupInvalid.Error()}, + } { + t.Run(test.code, func(t *testing.T) { + response := securityEnrollmentRequest("POST", "/api/verify", "", "", service.AuthIdentity{}, func(c *gin.Context) { + writeSecurityOperationError(c, fmt.Errorf("private database details: %w", test.err)) + }) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.False(t, body.Success) + assert.Equal(t, test.code, body.Code) + assert.Equal(t, test.message, body.Message) + assert.NotContains(t, response.Body.String(), "private") + }) + } +} + +func TestSecurityEnrollmentExpiredAndCrossSessionSetupsCannotActivate(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + setup, err := service.StartTwoFASetup(identity, authorizeSecurityEnrollment(t, identity)) + require.NoError(t, err) + code, err := totp.GenerateCode(setup.Secret, time.Now()) + require.NoError(t, err) + other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-session") + require.NoError(t, err) + otherIdentity, err := service.ParseAccessToken(other.AccessToken) + require.NoError(t, err) + assert.ErrorIs(t, service.FinishTwoFASetup(otherIdentity, setup.FlowToken, code), model.ErrTwoFASetupInvalid) + flow, err := model.GetAuthFlow(setup.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup}) + require.NoError(t, err) + require.NoError(t, model.DB.Model(flow).Update("expires_at", time.Now().Add(-time.Minute)).Error) + assert.ErrorIs(t, service.FinishTwoFASetup(identity, setup.FlowToken, code), model.ErrTwoFASetupInvalid) + stored, err := model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + assert.False(t, stored.IsEnabled) + storedUser, err := model.GetUserById(user.Id, false) + require.NoError(t, err) + assert.Equal(t, identity.UserAuthVersion, storedUser.AuthVersion) +} + +func TestSecurityEnrollmentOAuthQueriesReachHandlerWithoutLeakingToAccessLogs(t *testing.T) { + var output bytes.Buffer + previous := gin.DefaultWriter + gin.DefaultWriter = &output + t.Cleanup(func() { gin.DefaultWriter = previous }) + router := gin.New() + middleware.SetUpLogger(router) + router.GET("/api/oauth/:provider", func(c *gin.Context) { + assert.Equal(t, "private-code", c.Query("code")) + assert.Equal(t, "private-state", c.Query("state")) + c.Status(http.StatusNoContent) + }) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest("GET", "/api/oauth/github?code=private-code&state=private-state", nil)) + assert.Equal(t, http.StatusNoContent, response.Code) + assert.Contains(t, output.String(), "/api/oauth/github") + assert.NotContains(t, output.String(), "private-code") + assert.NotContains(t, output.String(), "private-state") +} + +func TestSecurityEnrollmentCustomOAuthUsesExistingBinding(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Update("password", "").Error) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/token": + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "custom-code", r.Form.Get("code")) + _, _ = w.Write([]byte(`{"access_token":"provider-access-token","token_type":"Bearer"}`)) + case "/userinfo": + assert.Equal(t, "Bearer provider-access-token", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"sub":"custom-user","name":"Existing user"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(upstream.Close) + provider := oauth.NewGenericOAuthProvider(&model.CustomOAuthProvider{ + Id: 42, Slug: "enrollment-custom", Name: "Custom provider", Enabled: true, + ClientId: "client", ClientSecret: "secret", UserIdField: "sub", + TokenEndpoint: upstream.URL + "/token", UserInfoEndpoint: upstream.URL + "/userinfo", + }) + oauth.RegisterCustom("enrollment-custom", provider) + t.Cleanup(func() { oauth.Unregister("enrollment-custom") }) + require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "custom-user"}).Error) + response := securityEnrollmentRequest("GET", "/api/verify/methods?scope=2fa.setup", "", "", identity, GetVerificationMethods) + assert.Contains(t, response.Body.String(), "enrollment-custom") + assert.NotContains(t, response.Body.String(), "custom-user") + response = securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"enrollment-custom","intent":"verify","scope":"2fa.setup"}`, "", identity, GenerateOAuthCode) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var started struct { + FlowToken string `json:"flow_token"` + } + require.NoError(t, common.Unmarshal(body.Data, &started)) + response = securityEnrollmentRequest("GET", "/api/oauth/enrollment-custom?state="+started.FlowToken+"&code=custom-code", "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "provider", Value: "enrollment-custom"}} + HandleOAuth(c) + }) + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var proof service.SecurityProof + require.NoError(t, common.Unmarshal(body.Data, &proof)) + _, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"}) + require.NoError(t, err) + bindings, err := model.GetUserOAuthBindingsByUserId(user.Id) + require.NoError(t, err) + require.Len(t, bindings, 1) + assert.Equal(t, "custom-user", bindings[0].ProviderUserId) +} + +func completeFirstSecurityFactor(t *testing.T, identity service.AuthIdentity, proof service.SecurityProof) { + t.Helper() + var response *httptest.ResponseRecorder + if proof.Scope == service.VerificationScopeTwoFASetup { + response = securityEnrollmentRequest("POST", "/api/user/2fa/setup", "", proof.ProofToken, identity, Setup2FA) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var setup service.TwoFASetup + require.NoError(t, common.Unmarshal(body.Data, &setup)) + code, err := totp.GenerateCode(setup.Secret, time.Now()) + require.NoError(t, err) + request, err := common.Marshal(Verify2FARequest{FlowToken: setup.FlowToken, Code: code}) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(request), "", identity, Enable2FA) + } else { + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof.ProofToken, identity, PasskeyRegisterBegin) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, body.Message) + var begin struct { + FlowToken string `json:"flow_token"` + Options struct { + PublicKey struct { + Challenge string `json:"challenge"` + } `json:"publicKey"` + } `json:"options"` + } + require.NoError(t, common.Unmarshal(body.Data, &begin)) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + request, err := common.Marshal(passkeyFinishRequest{ + FlowToken: begin.FlowToken, + Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0), + }) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(request), "", identity, PasskeyRegisterFinish) + } + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + var rotation struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, common.Unmarshal(body.Data, &rotation)) + updated, err := service.ParseAccessToken(rotation.AccessToken) + require.NoError(t, err) + assert.Equal(t, identity.UserID, updated.UserID) + assert.Equal(t, identity.UserAuthVersion+1, updated.UserAuthVersion) + _, err = service.ConsumeOperationProof(proof.ProofToken, updated, service.VerificationOperation{Scope: proof.Scope}) + assert.Error(t, err) +} + +func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) { + for _, provider := range []string{"telegram", "wechat"} { + for _, scope := range []string{service.VerificationScopeTwoFASetup, service.VerificationScopePasskeyRegister} { + t.Run(provider+"/"+scope, func(t *testing.T) { + var user *model.User + var identity service.AuthIdentity + var response *httptest.ResponseRecorder + method := service.VerificationMethodSession + if provider == "telegram" { + fixture := setupTelegramOAuthTest(t) + user, identity = fixture.user, fixture.identity + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "telegram_id": "42"}).Error) + state, code := fixture.authorization(t, "verify", identity, scope, telegramIdentityClaims(99)) + mismatch := telegramOAuthCallback(state, code, identity) + assert.Contains(t, mismatch.Body.String(), "OAUTH_ACCOUNT_MISMATCH") + assert.NotContains(t, mismatch.Body.String(), "proof_token") + state, code = fixture.authorization(t, "verify", identity, scope, telegramIdentityClaims(42)) + response = telegramOAuthCallback(state, code, identity) + method = service.VerificationMethodOAuth + } else { + user, identity = setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error) + request, err := common.Marshal(service.VerificationInput{Scope: scope, Method: method}) + require.NoError(t, err) + response = securityEnrollmentRequest("POST", "/api/verify", string(request), "", identity, UniversalVerify) + } + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + if provider == "wechat" { + assert.False(t, body.Success) + assert.NotContains(t, response.Body.String(), "proof_token") + return + } + require.True(t, body.Success, response.Body.String()) + var proof service.SecurityProof + require.NoError(t, common.Unmarshal(body.Data, &proof)) + assert.Equal(t, scope, proof.Scope) + assert.Equal(t, method, proof.Method) + before, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.Empty(t, before.Password) + assert.Equal(t, identity.UserAuthVersion, before.AuthVersion) + completeFirstSecurityFactor(t, identity, proof) + after, err := model.GetUserById(user.Id, true) + require.NoError(t, err) + assert.Equal(t, before.TelegramId, after.TelegramId) + assert.Equal(t, before.WeChatId, after.WeChatId) + }) + } + } +} + +func TestSecurityEnrollmentNeverTrustsSessionForFirstFactor(t *testing.T) { + for _, scenario := range []string{"wechat only", "password", "passkey", "locked 2fa", "telegram", "github", "disabled custom binding", "no binding", "binding storage failure", "revoked session"} { + t.Run(scenario, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error) + switch scenario { + case "password": + require.NoError(t, model.DB.Model(user).Update("password", "stored-hash").Error) + case "passkey": + require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "key"}).Error) + case "locked 2fa": + until := time.Now().Add(time.Hour) + require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true, LockedUntil: &until}).Error) + case "telegram": + require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error) + case "github": + require.NoError(t, model.DB.Model(user).Update("github_id", "42").Error) + case "disabled custom binding": + require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "linked"}).Error) + case "no binding": + require.NoError(t, model.DB.Model(user).Update("wechat_id", "").Error) + case "binding storage failure": + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("wechat_bindings_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "user_oauth_bindings" { + tx.AddError(errors.New("private binding failure")) + } + })) + case "revoked session": + _, err := model.RevokeAllUserSessions(user.Id, "test") + require.NoError(t, err) + } + for _, scope := range []string{"2fa.setup", "passkey.register", "passkey.delete", "channel.key.read"} { + context := json.RawMessage(nil) + if scope == "channel.key.read" { + context = json.RawMessage(`{"channel_id":1}`) + } + _, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "session", Scope: scope, Context: context}) + assert.Error(t, err, scope) + } + if scenario == "binding storage failure" { + require.NoError(t, model.DB.Callback().Query().Remove("wechat_bindings_failure")) + } + var count int64 + require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Count(&count).Error) + assert.Zero(t, count) + }) + } +} + +func TestSecurityEnrollmentMissingTargetsAreBusinessErrors(t *testing.T) { + _, identity := setupSecurityEnrollmentTest(t) + require.NoError(t, model.DB.AutoMigrate(&model.Channel{})) + for _, target := range []struct { + path, key string + handler gin.HandlerFunc + }{ + {"/api/channel/999/key", i18n.MsgChannelNotExists, GetChannelKey}, + {"/api/user/999/2fa", i18n.MsgUserNotExists, AdminDisable2FA}, + } { + var expectedMessage string + response := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: "999"}} + expectedMessage = i18n.T(c, target.key) + target.handler(c) + }) + assert.Equal(t, http.StatusOK, response.Code) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + assert.False(t, body.Success) + assert.Equal(t, expectedMessage, body.Message) + assert.NotEqual(t, "AUTH_UNAUTHORIZED", body.Code) + for _, id := range []string{"invalid", "0", "-1"} { + invalid := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: id}} + target.handler(c) + }) + assert.Equal(t, http.StatusOK, invalid.Code) + assert.Contains(t, invalid.Body.String(), `"success":false`) + } + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("target_query_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "users" || tx.Statement.Table == "channels" { + tx.AddError(errors.New("private database failure")) + } + })) + failed := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "id", Value: "999"}} + target.handler(c) + }) + require.NoError(t, model.DB.Callback().Query().Remove("target_query_failure")) + assert.Equal(t, http.StatusInternalServerError, failed.Code) + assert.NotContains(t, failed.Body.String(), "private database failure") + } + _, _, err := service.ValidateLoginSession(identity) + require.NoError(t, err) +} + +func TestSecurityEnrollmentRejectsChangedFirstFactorPolicy(t *testing.T) { + for _, provider := range []string{"telegram", "wechat"} { + for _, stage := range []string{"proof", "setup"} { + t.Run(provider+"/"+stage, func(t *testing.T) { + fixture := setupTelegramOAuthTest(t) + user, identity := fixture.user, fixture.identity + require.NoError(t, model.DB.Model(user).Update("password", "").Error) + var proof *service.SecurityProof + var err error + if provider == "telegram" { + require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error) + state, code := fixture.authorization(t, "verify", identity, "2fa.setup", telegramIdentityClaims(42)) + response := telegramOAuthCallback(state, code, identity) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + require.NoError(t, common.Unmarshal(body.Data, &proof)) + } else { + require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error) + proof, err = service.VerifySecurityInput(identity, service.VerificationInput{Scope: "2fa.setup", Method: "session"}) + require.Error(t, err) + assert.Nil(t, proof) + return + } + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"}) + assert.Error(t, err) + var setupToken string + if stage == "setup" { + response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof.ProofToken, identity, Setup2FA) + var body securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) + require.True(t, body.Success, response.Body.String()) + var setup struct { + FlowToken string `json:"flow_token"` + } + require.NoError(t, common.Unmarshal(body.Data, &setup)) + setupToken = setup.FlowToken + _, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"}) + assert.ErrorIs(t, err, service.ErrProofConsumed) + } + if provider == "telegram" { + common.TelegramOAuthEnabled = false + } else { + require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error) + } + if stage == "proof" { + response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof.ProofToken, identity, Setup2FA) + assert.Contains(t, response.Body.String(), `"success":false`) + assert.NotContains(t, response.Body.String(), "flow_token") + } else { + request, err := common.Marshal(map[string]string{"flow_token": setupToken, "code": "123456"}) + require.NoError(t, err) + response := securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(request), "", identity, Enable2FA) + assert.Contains(t, response.Body.String(), `"success":false`) + } + factor, err := model.GetTwoFAByUserId(user.Id) + require.NoError(t, err) + assert.True(t, factor == nil || !factor.IsEnabled) + }) + } + } +} diff --git a/controller/setup.go b/controller/setup.go index f6ee587e42b6..58a845743aad 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -85,16 +85,16 @@ func PostSetup(c *gin.Context) { return } - if len(req.Password) < 8 { + if err := common.ValidateNewAccountPassword(req.Password); err != nil { c.JSON(200, gin.H{ "success": false, - "message": "密码长度至少为8个字符", + "message": err.Error(), }) return } // Create root user - hashedPassword, err := common.Password2Hash(req.Password) + hashedPassword, err := common.HashAccountPassword(req.Password) if err != nil { c.JSON(200, gin.H{ "success": false, diff --git a/controller/subscription.go b/controller/subscription.go index 22cee9d5392a..599b82dcd938 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -281,7 +281,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { err := model.DB.Transaction(func(tx *gorm.DB) error { // update plan (allow zero values updates with map) - updateMap := map[string]interface{}{ + updateMap := map[string]any{ "title": req.Plan.Title, "subtitle": req.Plan.Subtitle, "price_amount": req.Plan.PriceAmount, @@ -407,13 +407,13 @@ func resolveAdvanceResetTime(value *bool) bool { return *value } -func recordSubscriptionResetUserLogs(result *model.SubscriptionResetResult, adminInfo map[string]interface{}) { +func recordSubscriptionResetUserLogs(c *gin.Context, result *model.SubscriptionResetResult, adminInfo *model.AuditAdminInfo) { if result == nil || result.ResetCount == 0 { return } content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId) for _, userId := range result.AffectedUserIds { - model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo) + model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo, nil, c) } } @@ -466,8 +466,8 @@ func AdminResetUserSubscriptionsByPlan(c *gin.Context) { common.ApiError(c, err) return } - recordSubscriptionResetUserLogs(result, auditOperatorInfo(c)) - recordManageAuditFor(c, userId, "subscription.user_plan_reset", map[string]interface{}{ + recordSubscriptionResetUserLogs(c, result, auditOperatorInfo(c)) + recordManageAuditFor(c, userId, "subscription.user_plan_reset", map[string]any{ "target_user_id": userId, "plan_id": result.PlanId, "plan_title": result.PlanTitle, @@ -495,10 +495,10 @@ func AdminResetPlanSubscriptions(c *gin.Context) { common.ApiError(c, err) return } - recordSubscriptionResetUserLogs(result, auditOperatorInfo(c)) + recordSubscriptionResetUserLogs(c, result, auditOperatorInfo(c)) common.SysLog(fmt.Sprintf("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t", result.PlanId, result.ResetCount, result.UserCount, result.AdvanceResetTime)) - recordManageAudit(c, "subscription.plan_reset", map[string]interface{}{ + recordManageAudit(c, "subscription.plan_reset", map[string]any{ "plan_id": result.PlanId, "plan_title": result.PlanTitle, "reset_count": result.ResetCount, diff --git a/controller/task_plugin.go b/controller/task_plugin.go index d88f44114bef..212f1b43f925 100644 --- a/controller/task_plugin.go +++ b/controller/task_plugin.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "net/http" "net/url" "sort" "strings" @@ -13,7 +15,6 @@ import ( "time" "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/jsplugin" @@ -31,6 +32,9 @@ type taskPluginUploadRequest struct { Remark string `json:"remark"` Force bool `json:"force"` SourceSha256 string `json:"sourceSha256"` + // Icon carries the sidecar icon.svg / icon.png as a data URI. It is optional + // and stored separately from the source so the JavaScript stays readable. + Icon string `json:"icon"` } func UploadTaskPlugin(c *gin.Context) { @@ -60,6 +64,13 @@ func UploadTaskPlugin(c *gin.Context) { common.ApiErrorMsg(c, err.Error()) return } + icon := strings.TrimSpace(request.Icon) + if icon != "" { + if _, _, err = jsplugin.DecodeIconDataURI(icon); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + } enabled := true if request.Enabled != nil { enabled = *request.Enabled @@ -73,7 +84,7 @@ func UploadTaskPlugin(c *gin.Context) { plugin := model.TaskPlugin{ Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version, Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))), - Enabled: enabled, Remark: request.Remark, + Icon: icon, Enabled: enabled, Remark: request.Remark, } if err = model.SaveTaskPlugin(&plugin); err != nil { common.ApiError(c, err) @@ -83,7 +94,7 @@ func UploadTaskPlugin(c *gin.Context) { common.ApiError(c, err) return } - common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"}) + common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()}) } func GetTaskPluginVersions(c *gin.Context) { @@ -101,6 +112,7 @@ type taskPluginListItem struct { Enabled bool `json:"enabled"` Active bool `json:"active"` SourceHash string `json:"source_hash"` + HasIcon bool `json:"has_icon"` Remark string `json:"remark"` RuntimeStatus string `json:"runtime_status"` RuntimeError string `json:"runtime_error,omitempty"` @@ -156,9 +168,7 @@ func ListTaskPlugins(c *gin.Context) { runtimeErrors := jsplugin.DefaultRegistry.RoutingErrors() taskPluginSyncState.Lock() - for key, message := range taskPluginSyncState.errors { - runtimeErrors[key] = message - } + maps.Copy(runtimeErrors, taskPluginSyncState.errors) taskPluginSyncState.Unlock() items := make([]taskPluginListItem, 0, len(keys)) @@ -180,10 +190,9 @@ func ListTaskPlugins(c *gin.Context) { item.Enabled = row.Enabled item.Active = row.Active item.SourceHash = row.SourceHash + item.HasIcon = row.HasIcon() item.Remark = row.Remark - if !constant.TaskPluginOverrideEnabled { - item.RuntimeStatus = "disabled_fallback" - } else if message := runtimeErrors[key]; message != "" { + if message := runtimeErrors[key]; message != "" { item.RuntimeStatus = "compile_failed" item.RuntimeError = message } else if runtimeMeta, ok := override[key]; ok { @@ -193,10 +202,16 @@ func ListTaskPlugins(c *gin.Context) { } else { item.RuntimeStatus = "not_registered" } + // "disabled_fallback" promises that the built-in still serves. When + // the factory layer is suppressed as well, nothing serves this key. + if item.RuntimeStatus == "disabled_fallback" && hasFactory && setting.IsTaskPluginFactoryDisabled(key) { + item.RuntimeStatus = "disabled" + } } else { item.Source = "factory" item.Meta = factoryMeta item.Enabled = !setting.IsTaskPluginFactoryDisabled(key) + _, _, item.HasIcon = plugins.Icon(key) source, sourceErr := plugins.Source(key) if sourceErr == nil { item.SourceHash = fmt.Sprintf("%x", sha256.Sum256([]byte(source))) @@ -219,7 +234,12 @@ func ListTaskPlugins(c *gin.Context) { } items = append(items, item) } - sort.Slice(items, func(i, j int) bool { return items[i].Meta.Key < items[j].Meta.Key }) + sort.Slice(items, func(i, j int) bool { + if items[i].Meta.SortPriority != items[j].Meta.SortPriority { + return items[i].Meta.SortPriority > items[j].Meta.SortPriority + } + return items[i].Meta.Key < items[j].Meta.Key + }) common.ApiSuccess(c, items) } @@ -228,9 +248,7 @@ func GetTaskPluginRuntime(c *gin.Context) { pluginErrors := routingStatus.Errors taskPluginSyncState.Lock() - for key, message := range taskPluginSyncState.errors { - pluginErrors[key] = message - } + maps.Copy(pluginErrors, taskPluginSyncState.errors) lastRebuild := taskPluginSyncState.lastRebuild lastDatabaseRevision := lastRebuild.DatabaseRevision taskPluginSyncState.Unlock() @@ -271,10 +289,43 @@ func GetTaskPluginRuntime(c *gin.Context) { } type taskPluginDetail struct { - Plugin *model.TaskPlugin `json:"plugin,omitempty"` - Meta jsplugin.Meta `json:"meta"` - Source string `json:"source"` - Layer string `json:"layer"` + Plugin *model.TaskPlugin `json:"plugin,omitempty"` + Meta jsplugin.Meta `json:"meta"` + Source string `json:"source"` + Layer string `json:"layer"` + HasIcon bool `json:"has_icon"` +} + +// GetTaskPluginIcon serves a plugin logo as an image. The active override wins +// (or the requested ?version=), then the factory sidecar. Data icons are only +// ever drawn through