Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/internal/handler/admin/account_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type DataProxy struct {
Status string `json:"status"`
}

// DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径,
// Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本,
// 应新增独立结构而非修改这里。
type DataAccount struct {
Name string `json:"name"`
Notes *string `json:"notes,omitempty"`
Expand Down
67 changes: 67 additions & 0 deletions backend/internal/handler/dto/account_mapper_redact_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package dto

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"

"github.com/Wei-Shaw/sub2api/internal/service"
)

func TestAccountFromServiceShallow_RedactsSensitiveCredentials(t *testing.T) {
src := &service.Account{
ID: 42,
Name: "demo",
Platform: "anthropic",
Type: "oauth",
Credentials: map[string]any{
"access_token": "at-secret",
"refresh_token": "rt-secret",
"id_token": "id-secret",
"api_key": "sk-secret",
"base_url": "https://api.example.com",
"model_mapping": map[string]any{"foo": "bar"},
},
}

got := AccountFromServiceShallow(src)
require.NotNil(t, got)

// 敏感键不在 Credentials 里
require.NotContains(t, got.Credentials, "access_token")
require.NotContains(t, got.Credentials, "refresh_token")
require.NotContains(t, got.Credentials, "id_token")
require.NotContains(t, got.Credentials, "api_key")
// 非敏感键保留
require.Equal(t, "https://api.example.com", got.Credentials["base_url"])
require.Equal(t, map[string]any{"foo": "bar"}, got.Credentials["model_mapping"])

// 状态 map 标记敏感键存在
require.True(t, got.CredentialsStatus["has_access_token"])
require.True(t, got.CredentialsStatus["has_refresh_token"])
require.True(t, got.CredentialsStatus["has_id_token"])
require.True(t, got.CredentialsStatus["has_api_key"])

// JSON 序列化校验:响应体里不会出现敏感子串
raw, err := json.Marshal(got)
require.NoError(t, err)
require.NotContains(t, string(raw), "rt-secret")
require.NotContains(t, string(raw), "at-secret")
require.NotContains(t, string(raw), "sk-secret")
require.NotContains(t, string(raw), "id-secret")
// 状态标识应序列化进 JSON
require.Contains(t, string(raw), "credentials_status")
require.Contains(t, string(raw), "has_refresh_token")

// 原始 service.Account 不应被改动
require.Equal(t, "rt-secret", src.Credentials["refresh_token"])
}

func TestAccountFromServiceShallow_NilCredentialsOmitsStatus(t *testing.T) {
src := &service.Account{ID: 1, Name: "n", Platform: "anthropic", Type: "oauth"}
got := AccountFromServiceShallow(src)
require.NotNil(t, got)
require.Nil(t, got.Credentials)
require.Nil(t, got.CredentialsStatus)
}
44 changes: 44 additions & 0 deletions backend/internal/handler/dto/credentials_redact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Package dto provides data transfer objects for HTTP handlers.
package dto

import "github.com/Wei-Shaw/sub2api/internal/service"

// RedactCredentials 复制一份 in,剥离 service.SensitiveCredentialKeys 列出的所有敏感子键,
// 并产出一个 has_<key> 状态 map 表示哪些敏感键存在且非零值。
//
// 输入 nil 时返回 nil, nil(避免响应里出现空对象)。
// 不修改入参;调用方拿到的 out 可安全序列化进 JSON 返回前端。
func RedactCredentials(in map[string]any) (out map[string]any, status map[string]bool) {
if in == nil {
return nil, nil
}
out = make(map[string]any, len(in))
for k, v := range in {
if service.IsSensitiveCredentialKey(k) {
if isCredentialValuePresent(v) {
if status == nil {
status = make(map[string]bool, 4)
}
status["has_"+k] = true
}
continue
}
out[k] = v
}
return out, status
}

// isCredentialValuePresent 判断值是否"存在且非零"。空字符串、nil、false 均视为未配置;
// 其余非零类型(数字、对象、字符串等)视为已配置。
func isCredentialValuePresent(v any) bool {
switch x := v.(type) {
case nil:
return false
case string:
return x != ""
case bool:
return x
default:
return true
}
}
97 changes: 97 additions & 0 deletions backend/internal/handler/dto/credentials_redact_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package dto

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestRedactCredentials_NilInput(t *testing.T) {
out, status := RedactCredentials(nil)
require.Nil(t, out)
require.Nil(t, status)
}

func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
in := map[string]any{
"refresh_token": "rt-secret",
"access_token": "at-secret",
"api_key": "sk-secret",
"aws_secret_access_key": "aws-secret",
"service_account_json": map[string]any{"private_key": "..."},
"private_key": "raw-key",
// 非敏感
"base_url": "https://api.example.com",
"model_mapping": map[string]any{"foo": "bar"},
"project_id": "proj-1",
"expires_at": int64(123456),
}

out, status := RedactCredentials(in)

require.NotContains(t, out, "refresh_token")
require.NotContains(t, out, "access_token")
require.NotContains(t, out, "api_key")
require.NotContains(t, out, "aws_secret_access_key")
require.NotContains(t, out, "service_account_json")
require.NotContains(t, out, "private_key")

require.Equal(t, "https://api.example.com", out["base_url"])
require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"])
require.Equal(t, "proj-1", out["project_id"])
require.Equal(t, int64(123456), out["expires_at"])

require.True(t, status["has_refresh_token"])
require.True(t, status["has_access_token"])
require.True(t, status["has_api_key"])
require.True(t, status["has_aws_secret_access_key"])
require.True(t, status["has_service_account_json"])
require.True(t, status["has_private_key"])

// 状态 map 不应携带非敏感键的 has_*
require.NotContains(t, status, "has_base_url")
require.NotContains(t, status, "has_project_id")
}

func TestRedactCredentials_EmptyValuesNotMarkedPresent(t *testing.T) {
in := map[string]any{
"refresh_token": "",
"access_token": nil,
"api_key": false,
"id_token": "actual-id",
}
out, status := RedactCredentials(in)
require.Empty(t, out, "敏感键即使为空也不应出现在 redacted output")
require.False(t, status["has_refresh_token"])
require.False(t, status["has_access_token"])
require.False(t, status["has_api_key"])
require.True(t, status["has_id_token"])
}

func TestRedactCredentials_DoesNotMutateInput(t *testing.T) {
in := map[string]any{
"refresh_token": "secret",
"base_url": "x",
}
_, _ = RedactCredentials(in)
require.Equal(t, "secret", in["refresh_token"], "原始 map 不应被修改")
require.Equal(t, "x", in["base_url"])
}

func TestRedactCredentials_AllKnownSensitiveKeys(t *testing.T) {
keys := []string{
"access_token", "refresh_token", "id_token",
"api_key", "session_key", "cookie",
"aws_secret_access_key", "aws_session_token",
"service_account_json", "service_account", "private_key",
}
in := make(map[string]any, len(keys))
for _, k := range keys {
in[k] = "filled"
}
out, status := RedactCredentials(in)
require.Empty(t, out)
for _, k := range keys {
require.True(t, status["has_"+k], "key %s 应在 status 中标记为已配置", k)
}
}
4 changes: 3 additions & 1 deletion backend/internal/handler/dto/mappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,15 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if a == nil {
return nil
}
redactedCreds, credsStatus := RedactCredentials(a.Credentials)
out := &Account{
ID: a.ID,
Name: a.Name,
Notes: a.Notes,
Platform: a.Platform,
Type: a.Type,
Credentials: a.Credentials,
Credentials: redactedCreds,
CredentialsStatus: credsStatus,
Extra: a.Extra,
ProxyID: a.ProxyID,
Concurrency: a.Concurrency,
Expand Down
41 changes: 22 additions & 19 deletions backend/internal/handler/dto/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,25 +149,28 @@ type AdminGroup struct {
}

type Account struct {
ID int64 `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor,omitempty"`
Priority int `json:"priority"`
RateMultiplier float64 `json:"rate_multiplier"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
// Credentials 经 RedactCredentials 处理后只含非敏感子键;敏感 token / api_key / 私钥
// 的存在性通过 CredentialsStatus(has_<key>)暴露,原始值不返回前端。
Credentials map[string]any `json:"credentials"`
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor,omitempty"`
Priority int `json:"priority"`
RateMultiplier float64 `json:"rate_multiplier"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

Schedulable bool `json:"schedulable"`

Expand Down
50 changes: 50 additions & 0 deletions backend/internal/service/account_credentials_redact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package service

// SensitiveCredentialKeys 列出 Account.Credentials JSON map 中绝不允许返回到前端的子键。
// dto 层做响应脱敏、service 层做更新合并都引用此清单——新增凭证类型时务必同步。
var SensitiveCredentialKeys = []string{
// OAuth
"access_token", "refresh_token", "id_token",
// API Key 类
"api_key", "session_key", "cookie",
// 云服务凭据
"aws_secret_access_key", "aws_session_token",
"service_account_json", "service_account", "private_key",
}

var sensitiveCredentialKeySet = func() map[string]struct{} {
m := make(map[string]struct{}, len(SensitiveCredentialKeys))
for _, k := range SensitiveCredentialKeys {
m[k] = struct{}{}
}
return m
}()

// IsSensitiveCredentialKey 判断指定键是否为敏感凭证子键。
func IsSensitiveCredentialKey(key string) bool {
_, ok := sensitiveCredentialKeySet[key]
return ok
}

// MergePreservingSensitiveCreds 把 incoming 写入 existing 之上,但敏感子键采用"incoming 没提供就保留 existing"
// 的语义。返回新的 map,不修改入参。
//
// 用途:前端编辑账号通常采用"全对象 PUT"模式;脱敏后前端 spread 旧 credentials 时不会带上敏感键,
// 直接覆盖会清空已有 token。此函数保证:
// - 非敏感键:完全由 incoming 决定(用户可以编辑、删除非敏感字段)。
// - 敏感键:incoming 显式提供则覆盖(用户主动旋转 token),否则保留 existing。
func MergePreservingSensitiveCreds(existing, incoming map[string]any) map[string]any {
out := make(map[string]any, len(incoming)+len(SensitiveCredentialKeys))
for k, v := range incoming {
out[k] = v
}
for _, key := range SensitiveCredentialKeys {
if _, hasIncoming := incoming[key]; hasIncoming {
continue
}
if existingVal, ok := existing[key]; ok {
out[key] = existingVal
}
}
return out
}
Loading
Loading