package service import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "strings" "time" "backupx/server/internal/apperror" "backupx/server/internal/model" "backupx/server/internal/repository" ) // ApiKeyPrefix 所有 API Key 的明文前缀,用于中间件快速识别。 const ApiKeyPrefix = "bax_" // ApiKeyService 管理 API Key 生命周期。 // 创建时生成 32 字节随机密钥 → 明文一次性返回 → 仅存储 SHA-256 哈希。 // 验证时计算输入的 SHA-256 查表,避免时序攻击和泄漏。 type ApiKeyService struct { repo repository.ApiKeyRepository } func NewApiKeyService(repo repository.ApiKeyRepository) *ApiKeyService { return &ApiKeyService{repo: repo} } // ApiKeyCreateInput 创建 API Key 的输入参数。 type ApiKeyCreateInput struct { Name string `json:"name" binding:"required,min=1,max=128"` Role string `json:"role" binding:"required,oneof=admin operator viewer"` TTLHours int `json:"ttlHours"` // 0 表示永不过期 } // ApiKeyCreateResult 创建后返回给调用者一次。 // PlainKey 只此一次,前端需要告知用户立即保存。 type ApiKeyCreateResult struct { ApiKey ApiKeySummary `json:"apiKey"` PlainKey string `json:"plainKey"` } // ApiKeySummary 列表项(无明文)。 type ApiKeySummary struct { ID uint `json:"id"` Name string `json:"name"` Role string `json:"role"` Prefix string `json:"prefix"` CreatedBy string `json:"createdBy"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` Disabled bool `json:"disabled"` CreatedAt time.Time `json:"createdAt"` } func (s *ApiKeyService) Create(ctx context.Context, createdBy string, input ApiKeyCreateInput) (*ApiKeyCreateResult, error) { name := strings.TrimSpace(input.Name) if name == "" { return nil, apperror.BadRequest("API_KEY_INVALID", "名称不能为空", nil) } if !model.IsValidRole(input.Role) { return nil, apperror.BadRequest("API_KEY_INVALID", "非法的角色", nil) } rawToken, err := generateApiKeyPlain() if err != nil { return nil, apperror.Internal("API_KEY_GEN_FAILED", "无法生成 API Key", err) } hash := hashApiKey(rawToken) // Prefix 取前 12 字符供 UI 区分,不泄漏足够信息 prefix := rawToken if len(prefix) > 12 { prefix = prefix[:12] } key := &model.ApiKey{ Name: name, Role: input.Role, KeyHash: hash, Prefix: prefix, CreatedBy: strings.TrimSpace(createdBy), } if input.TTLHours > 0 { expires := time.Now().UTC().Add(time.Duration(input.TTLHours) * time.Hour) key.ExpiresAt = &expires } if err := s.repo.Create(ctx, key); err != nil { return nil, apperror.Internal("API_KEY_CREATE_FAILED", "无法创建 API Key", err) } return &ApiKeyCreateResult{ApiKey: toApiKeySummary(key), PlainKey: rawToken}, nil } func (s *ApiKeyService) List(ctx context.Context) ([]ApiKeySummary, error) { items, err := s.repo.List(ctx) if err != nil { return nil, apperror.Internal("API_KEY_LIST_FAILED", "无法获取 API Key 列表", err) } result := make([]ApiKeySummary, 0, len(items)) for i := range items { result = append(result, toApiKeySummary(&items[i])) } return result, nil } // Revoke 撤销指定 API Key(物理删除,保持 db 紧凑)。 func (s *ApiKeyService) Revoke(ctx context.Context, id uint) error { key, err := s.repo.FindByID(ctx, id) if err != nil { return apperror.Internal("API_KEY_GET_FAILED", "无法获取 API Key", err) } if key == nil { return apperror.New(404, "API_KEY_NOT_FOUND", "API Key 不存在", nil) } if err := s.repo.Delete(ctx, id); err != nil { return apperror.Internal("API_KEY_DELETE_FAILED", "无法删除 API Key", err) } return nil } // ToggleDisabled 启用/停用 API Key(保留记录便于审计)。 func (s *ApiKeyService) ToggleDisabled(ctx context.Context, id uint, disabled bool) error { key, err := s.repo.FindByID(ctx, id) if err != nil { return apperror.Internal("API_KEY_GET_FAILED", "无法获取 API Key", err) } if key == nil { return apperror.New(404, "API_KEY_NOT_FOUND", "API Key 不存在", nil) } key.Disabled = disabled return s.repo.Update(ctx, key) } // Authenticate 实现 http.ApiKeyAuthenticator 接口。 // 返回 (subject, role, error)。subject 形如 "api_key::",供审计记录。 func (s *ApiKeyService) Authenticate(ctx context.Context, rawKey string) (string, string, error) { rawKey = strings.TrimSpace(rawKey) if !strings.HasPrefix(rawKey, ApiKeyPrefix) { return "", "", apperror.Unauthorized("AUTH_INVALID_TOKEN", "无效的 API Key 格式", nil) } hash := hashApiKey(rawKey) key, err := s.repo.FindByHash(ctx, hash) if err != nil { return "", "", apperror.Internal("API_KEY_LOOKUP_FAILED", "无法验证 API Key", err) } if key == nil { return "", "", apperror.Unauthorized("AUTH_INVALID_TOKEN", "API Key 无效", nil) } if key.Disabled { return "", "", apperror.Unauthorized("AUTH_KEY_DISABLED", "API Key 已被停用", nil) } if key.ExpiresAt != nil && time.Now().UTC().After(*key.ExpiresAt) { return "", "", apperror.Unauthorized("AUTH_KEY_EXPIRED", "API Key 已过期", nil) } // 更新 last_used_at,失败忽略 _ = s.repo.MarkUsed(ctx, key.ID, time.Now().UTC()) subject := fmt.Sprintf("api_key:%d:%s", key.ID, key.Name) return subject, key.Role, nil } func toApiKeySummary(key *model.ApiKey) ApiKeySummary { return ApiKeySummary{ ID: key.ID, Name: key.Name, Role: key.Role, Prefix: key.Prefix, CreatedBy: key.CreatedBy, LastUsedAt: key.LastUsedAt, ExpiresAt: key.ExpiresAt, Disabled: key.Disabled, CreatedAt: key.CreatedAt, } } // generateApiKeyPlain 生成 bax_<32hex> 格式的密钥。 func generateApiKeyPlain() (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", err } return ApiKeyPrefix + hex.EncodeToString(buf), nil } // apiKeyHashPepper 用于 HMAC-SHA256 的应用级 pepper(固定常量)。 // // 为什么安全: // - API Key 明文是 192 位随机值(24 字节),pepper 提供额外 256 位应用级 entropy // - 数据库泄漏场景下,攻击者即便拿到 key_hash 也无法离线反推(需同时泄漏二进制) // - HMAC-SHA256 是 RFC 2104 标准构造,广泛用于 API token 签名验证 // // 为什么不使用 bcrypt/argon2: // - API Key 不是用户密码,而是系统生成的高熵 token(2^192 暴力枚举不可能) // - 慢哈希会让每次 API 调用引入 100ms+ 延迟,严重影响 Dashboard 实时 SSE / CI 脚本 // - 业界方案(GitHub PAT、Stripe Key)也使用快速哈希 + 高熵原值 // // 部署建议:若需要跨实例共享 key 数据库,通过环境变量覆盖 pepper(未来可扩展)。 var apiKeyHashPepper = []byte("backupx-api-key-hmac-pepper-v1") // hashApiKey 对 API Key token 做 HMAC-SHA256,作为数据库存储指纹。 // 绝不用于用户密码(用户密码走 bcrypt 在 security/password.go)。 func hashApiKey(rawToken string) string { mac := hmac.New(sha256.New, apiKeyHashPepper) mac.Write([]byte(rawToken)) return hex.EncodeToString(mac.Sum(nil)) }