Files
hfb_sys/backend/internal/config/config.go
T
yml 8fef037be3 AES 字段加密迁配置并兼容旧密文
移除字段加密硬编码主密钥,改为 FIELD_ENCRYPTION_KEY 注入。

保留 FIELD_ENCRYPTION_LEGACY_KEY 透明回退旧密文,新写入统一使用主密钥;生产环境校验主密钥和 legacy 密钥长度、占位符及相等关系,并统一生产环境判断口径。

补充配置与旧密文兼容回归测试。
2026-06-14 17:53:24 +08:00

222 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"errors"
"os"
"strconv"
"strings"
)
type Config struct {
AppEnv string
AppAddr string
MySQLDSN string
RedisAddr string
RedisPassword string
RedisDB int
JWTSecret string
PaymentConfigEncryptionKey string
FieldEncryptionKey string
FieldEncryptionLegacyKey string
ExternalUploadSecret string
ExternalUploadAllowedIPs []string
BootstrapAdminUsername string
BootstrapAdminPassword string
BootstrapAdminNickname string
Storage StorageConfig
SMS SMSConfig
Realname RealnameConfig
Log LogConfig
RateLimit RateLimitConfig
}
type StorageConfig struct {
Endpoint string
Bucket string
AccessKeyID string
SecretAccessKey string
}
type SMSConfig struct {
Provider string
AliyunAccessKeyID string
AliyunAccessKeySecret string
AliyunEndpoint string
AliyunSignName string
AliyunLoginTemplateCode string
}
type RealnameConfig struct {
Provider string
CloudMarketURL string
CloudMarketAppCode string
}
type LogConfig struct {
Level string
Dir string
EnableConsole bool
EnableFile bool
}
type RateLimitConfig struct {
Enabled bool
RequestsPerMinute int
}
func Load() Config {
return Config{
AppEnv: getEnv("APP_ENV", "development"),
AppAddr: getEnv("APP_ADDR", ":8080"),
MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: getEnvInt("REDIS_DB", 0),
JWTSecret: getEnv("JWT_SECRET", "change-me"),
PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""),
FieldEncryptionKey: getEnv("FIELD_ENCRYPTION_KEY", ""),
// 旧密钥回退:生产环境必须显式设置(空 = 禁用回退,用于密钥轮换收敛);
// 非生产默认填历史硬编码密钥,兼容开发/测试库的存量密文。不能用 getEnv 的 fallback——
// 那样生产删掉 env 会重新注入已泄露的硬编码密钥,破坏轮换闭环。
FieldEncryptionLegacyKey: fieldEncryptionLegacyKey(getEnv("APP_ENV", "development")),
ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""),
ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"),
BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""),
BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""),
BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"),
Storage: StorageConfig{
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "minioadmin"),
SecretAccessKey: getEnv("STORAGE_SECRET_ACCESS_KEY", "minioadmin"),
},
SMS: SMSConfig{
Provider: getEnv("SMS_PROVIDER", "mock"),
AliyunAccessKeyID: getEnv("ALIYUN_ACCESS_KEY_ID", ""),
AliyunAccessKeySecret: getEnv("ALIYUN_ACCESS_KEY_SECRET", ""),
AliyunEndpoint: getEnv("ALIYUN_SMS_ENDPOINT", "dysmsapi.aliyuncs.com"),
AliyunSignName: getEnv("ALIYUN_SMS_SIGN_NAME", ""),
AliyunLoginTemplateCode: getEnv("ALIYUN_SMS_LOGIN_TEMPLATE_CODE", ""),
},
Realname: RealnameConfig{
Provider: getEnv("REALNAME_PROVIDER", "mock"),
CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://sinocheck2.market.alicloudapi.com/fortest/ttttt"),
CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""),
},
Log: LogConfig{
Level: getEnv("LOG_LEVEL", "info"),
Dir: getEnv("LOG_DIR", "logs"),
EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true),
EnableFile: getEnvBool("LOG_ENABLE_FILE", true),
},
RateLimit: RateLimitConfig{
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
},
}
}
func (c Config) ValidateProductionSecurity() error {
if !IsProductionEnv(c.AppEnv) {
return nil
}
if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 {
return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production")
}
keyLen := len([]byte(c.PaymentConfigEncryptionKey))
if isPlaceholder(c.PaymentConfigEncryptionKey) || (keyLen != 16 && keyLen != 24 && keyLen != 32) {
return errors.New("PAYMENT_CONFIG_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production")
}
fieldKeyLen := len([]byte(c.FieldEncryptionKey))
if c.FieldEncryptionKey == "" || isPlaceholder(c.FieldEncryptionKey) || (fieldKeyLen != 16 && fieldKeyLen != 24 && fieldKeyLen != 32) {
return errors.New("FIELD_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production")
}
legacyKeyLen := len([]byte(c.FieldEncryptionLegacyKey))
if c.FieldEncryptionLegacyKey != "" && (isPlaceholder(c.FieldEncryptionLegacyKey) || (legacyKeyLen != 16 && legacyKeyLen != 24 && legacyKeyLen != 32)) {
return errors.New("FIELD_ENCRYPTION_LEGACY_KEY must be empty or 16, 24, or 32 bytes in production")
}
if c.FieldEncryptionKey == c.FieldEncryptionLegacyKey {
return errors.New("FIELD_ENCRYPTION_KEY must differ from FIELD_ENCRYPTION_LEGACY_KEY in production (set a new primary key to rotate)")
}
if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) {
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production")
}
return nil
}
func getEnv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
// historicFieldEncryptionKey 是迁移前 pkg/crypto 的硬编码密钥,仅用于解密历史存量密文。
const historicFieldEncryptionKey = "hfb-sys-2024-secret-key-32bytes!"
// fieldEncryptionLegacyKey 解析 FIELD_ENCRYPTION_LEGACY_KEY
// - 生产环境:必须显式设置,空表示禁用回退(密钥轮换收敛后删除 env 即关闭旧密钥)。
// 不能用硬编码默认值,否则轮换闭环不成立。
// - 非生产环境:未设置时回退到历史硬编码密钥,兼容开发/测试库的存量密文(零配置)。
func fieldEncryptionLegacyKey(appEnv string) string {
if IsProductionEnv(appEnv) {
// 生产显式空 = 禁用;未设置也视为禁用。
return os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY")
}
if v := os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY"); v != "" {
return v
}
return historicFieldEncryptionKey
}
func getEnvInt(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func getEnvBool(key string, fallback bool) bool {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func getEnvList(key string) []string {
raw := os.Getenv(key)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value != "" {
values = append(values, value)
}
}
return values
}
func isPlaceholder(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-")
}
// IsProductionEnv 判断 AppEnv 是否为生产环境(与 ValidateProductionSecurity 同口径)。
func IsProductionEnv(appEnv string) bool {
return strings.ToLower(strings.TrimSpace(appEnv)) == "production"
}