feat(admin): add atomic task claim leases
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -23,6 +25,8 @@ const (
|
||||
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
||||
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
||||
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
||||
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
|
||||
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
@@ -37,6 +41,8 @@ type Config struct {
|
||||
MaxTaskQuantity int
|
||||
MaxTotalPrice string
|
||||
EvidenceDirectory string
|
||||
ClaimTokenSecret []byte
|
||||
ClaimLeaseTTL time.Duration
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
@@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
|
||||
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
|
||||
}
|
||||
claimSecretText, err := required(lookup, claimTokenSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimSecret, err := hex.DecodeString(claimSecretText)
|
||||
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
|
||||
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
|
||||
}
|
||||
// Claim ownership, admin sessions and device authentication are separate security domains.
|
||||
// Reject both identical configuration text and identical effective key bytes.
|
||||
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
|
||||
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
|
||||
}
|
||||
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimTTL, err := time.ParseDuration(claimTTLText)
|
||||
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
|
||||
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
@@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
DatabaseSource: databaseSource,
|
||||
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
||||
EvidenceDirectory: evidenceDirectory,
|
||||
ClaimTokenSecret: claimSecret,
|
||||
ClaimLeaseTTL: claimTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package config_test
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
@@ -25,13 +26,15 @@ func TestLoad(t *testing.T) {
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +54,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -68,6 +73,12 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
|
||||
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same raw session secret", func(values map[string]string) {
|
||||
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
|
||||
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
Reference in New Issue
Block a user