feat(admin): add administrator sessions
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
|
||||
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
|
||||
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
|
||||
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
// Config 是启动采购服务所需的最小安全配置。
|
||||
type Config struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
SessionSecret []byte
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
func LoadFromEnv() (Config, error) {
|
||||
return Load(os.LookupEnv)
|
||||
}
|
||||
|
||||
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
|
||||
func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
username, err := required(lookup, adminUsernameEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
||||
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
|
||||
}
|
||||
|
||||
secret, err := required(lookup, sessionSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if len([]byte(secret)) < minimumSecretLength {
|
||||
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
|
||||
}
|
||||
|
||||
cookieSecure := false
|
||||
if value, present := lookup(cookieSecureEnv); present {
|
||||
switch value {
|
||||
case "true":
|
||||
cookieSecure = true
|
||||
case "false":
|
||||
cookieSecure = false
|
||||
default:
|
||||
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
|
||||
}
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
AdminPasswordBcrypt: passwordHash,
|
||||
SessionSecret: []byte(secret),
|
||||
CookieSecure: cookieSecure,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func required(lookup func(string) (string, bool), name string) (string, error) {
|
||||
value, present := lookup(name)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return "", errors.New(name + " must be set")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
values := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
"CMBUYER_COOKIE_SECURE": "true",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
base := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]string)
|
||||
want string
|
||||
}{
|
||||
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
|
||||
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
|
||||
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
|
||||
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
values := copyValues(base)
|
||||
test.mutate(values)
|
||||
_, err := config.Load(lookup(values))
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
value, ok := values[key]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
|
||||
func copyValues(values map[string]string) map[string]string {
|
||||
copy := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
Reference in New Issue
Block a user