From 5dcff4b15a3087d3e4601201bc2dbb3a98e2a371 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 18:24:39 +0800 Subject: [PATCH] feat(admin): authorize batch purchase starts --- admin/README.md | 7 + admin/cmd/server/main.go | 1 + admin/internal/auth/session.go | 6 + admin/internal/auth/session_test.go | 45 ++ admin/internal/config/config.go | 47 ++ admin/internal/config/config_test.go | 9 + admin/internal/server/router.go | 155 ++++++- admin/internal/server/router_test.go | 130 +++++- admin/internal/server/start_purchases_test.go | 328 ++++++++++++++ admin/internal/tasks/authorization.go | 128 ++++++ .../tasks/start_purchases_store_test.go | 424 ++++++++++++++++++ admin/internal/tasks/start_store.go | 229 ++++++++++ admin/internal/tasks/start_store_test.go | 195 ++++++++ admin/internal/tasks/store.go | 20 +- .../internal/transport/webui/static/tasks.js | 60 +++ .../transport/webui/static/tasks.test.js | 138 ++++++ .../transport/webui/templates/tasks.html | 19 +- admin/internal/transport/webui/webui.go | 56 ++- 18 files changed, 1960 insertions(+), 37 deletions(-) create mode 100644 admin/internal/server/start_purchases_test.go create mode 100644 admin/internal/tasks/authorization.go create mode 100644 admin/internal/tasks/start_purchases_store_test.go create mode 100644 admin/internal/tasks/start_store.go create mode 100644 admin/internal/tasks/start_store_test.go create mode 100644 admin/internal/transport/webui/static/tasks.js create mode 100644 admin/internal/transport/webui/static/tasks.test.js diff --git a/admin/README.md b/admin/README.md index 2ef7b8f..7273cd2 100644 --- a/admin/README.md +++ b/admin/README.md @@ -9,6 +9,9 @@ | `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 | | `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 | | `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 | +| `CMBUYER_AUTHORIZATION_TTL` | 一次性授权的正 Go duration,例如 `10m`。 | +| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 | +| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 | 示例仅展示变量名,不提供可运行凭据: @@ -18,8 +21,12 @@ $env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '' $env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>' $env:CMBUYER_COOKIE_SECURE = 'true' $env:CMBUYER_DATABASE_SOURCE = '' +$env:CMBUYER_AUTHORIZATION_TTL = '10m' +$env:CMBUYER_MAX_TASK_QUANTITY = '99' +$env:CMBUYER_MAX_TOTAL_PRICE = '999.99' go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up go run ./cmd/server ``` 采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。 +管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。 diff --git a/admin/cmd/server/main.go b/admin/cmd/server/main.go index ff1596b..e203e25 100644 --- a/admin/cmd/server/main.go +++ b/admin/cmd/server/main.go @@ -34,6 +34,7 @@ func run() error { if err != nil { return err } + taskStore.SetStartPolicy(tasks.StartPolicy{AuthorizationTTL: configuration.AuthorizationTTL, MaxQuantity: configuration.MaxTaskQuantity, MaxTotalPrice: configuration.MaxTotalPrice}) router, err := server.NewRouter(server.Options{ AdminUsername: configuration.AdminUsername, diff --git a/admin/internal/auth/session.go b/admin/internal/auth/session.go index e2d208b..53667f8 100644 --- a/admin/internal/auth/session.go +++ b/admin/internal/auth/session.go @@ -62,6 +62,12 @@ func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request return current.csrfToken, false } +// IsAuthenticated 只读检查当前请求是否持有有效管理会话;它不会像 Ensure 一样创建匿名会话。 +func (manager *Manager) IsAuthenticated(request *http.Request) bool { + _, current, found := manager.current(request) + return found && current.authenticated +} + // VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。 func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) { _, current, found := manager.current(request) diff --git a/admin/internal/auth/session_test.go b/admin/internal/auth/session_test.go index 2b542ae..48ce87d 100644 --- a/admin/internal/auth/session_test.go +++ b/admin/internal/auth/session_test.go @@ -37,6 +37,51 @@ func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) { } } +func TestIsAuthenticatedDoesNotCreateOrDependOnCSRFValidation(t *testing.T) { + manager := NewManager([]byte(strings.Repeat("s", 32)), false) + missingSession := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil) + if manager.IsAuthenticated(missingSession) { + t.Fatal("missing session was treated as authenticated") + } + if len(manager.sessions) != 0 { + t.Fatalf("read-only authentication check created %d sessions", len(manager.sessions)) + } + anonymousRequest := httptest.NewRequest(http.MethodGet, "/login", nil) + anonymousResponse := httptest.NewRecorder() + manager.Ensure(anonymousResponse, anonymousRequest) + anonymousCookie := anonymousResponse.Result().Cookies()[0] + anonymousCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil) + anonymousCheck.AddCookie(anonymousCookie) + if manager.IsAuthenticated(anonymousCheck) { + t.Fatal("anonymous CSRF session was treated as authenticated") + } + + loginRequest := httptest.NewRequest(http.MethodPost, "/login", nil) + loginRequest.AddCookie(anonymousCookie) + authenticatedResponse := httptest.NewRecorder() + csrf := manager.RotateAuthenticated(authenticatedResponse, loginRequest) + authenticatedCookie := authenticatedResponse.Result().Cookies()[0] + + authenticatedCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil) + authenticatedCheck.AddCookie(authenticatedCookie) + if !manager.IsAuthenticated(authenticatedCheck) { + t.Fatal("valid authenticated session was not recognized") + } + if authenticated, csrfOK := manager.VerifyCSRF(authenticatedCheck, "wrong-token"); authenticated || csrfOK { + t.Fatalf("wrong token result = (%t, %t), want (false, false)", authenticated, csrfOK) + } + + validRequest := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil) + validRequest.AddCookie(authenticatedCookie) + if authenticated, csrfOK := manager.VerifyCSRF(validRequest, csrf); !authenticated || !csrfOK { + t.Fatalf("valid token result = (%t, %t), want (true, true)", authenticated, csrfOK) + } + + if authenticated, csrfOK := manager.VerifyCSRF(httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil), csrf); authenticated || csrfOK { + t.Fatalf("missing session result = (%t, %t), want (false, false)", authenticated, csrfOK) + } +} + func flipCookieValue(t *testing.T, value string) string { t.Helper() if value == "" { diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go index 81c63c4..d604c38 100644 --- a/admin/internal/config/config.go +++ b/admin/internal/config/config.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "os" + "strconv" "strings" + "time" "golang.org/x/crypto/bcrypt" ) @@ -16,6 +18,9 @@ const ( sessionSecretEnv = "CMBUYER_SESSION_SECRET" cookieSecureEnv = "CMBUYER_COOKIE_SECURE" databaseSourceEnv = "CMBUYER_DATABASE_SOURCE" + authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL" + maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY" + maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE" minimumSecretLength = 32 ) @@ -26,6 +31,9 @@ type Config struct { SessionSecret []byte CookieSecure bool DatabaseSource string + AuthorizationTTL time.Duration + MaxTaskQuantity int + MaxTotalPrice string } // LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。 @@ -71,6 +79,29 @@ func Load(lookup func(string) (string, bool)) (Config, error) { if err != nil { return Config{}, err } + ttlText, err := required(lookup, authorizationTTLEnv) + if err != nil { + return Config{}, err + } + ttl, err := time.ParseDuration(ttlText) + if err != nil || ttl <= 0 { + return Config{}, fmt.Errorf("%s must be a positive duration", authorizationTTLEnv) + } + quantityText, err := required(lookup, maxTaskQuantityEnv) + if err != nil { + return Config{}, err + } + maxQuantity, err := strconv.Atoi(quantityText) + if err != nil || maxQuantity < 1 { + return Config{}, fmt.Errorf("%s must be a positive integer", maxTaskQuantityEnv) + } + maxPrice, err := required(lookup, maxTotalPriceEnv) + if err != nil { + return Config{}, err + } + if !canonicalMoney(maxPrice) { + return Config{}, fmt.Errorf("%s must be a canonical positive decimal", maxTotalPriceEnv) + } return Config{ AdminUsername: username, @@ -78,9 +109,25 @@ func Load(lookup func(string) (string, bool)) (Config, error) { SessionSecret: []byte(secret), CookieSecure: cookieSecure, DatabaseSource: databaseSource, + AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice, }, nil } +func canonicalMoney(value string) bool { + parts := strings.Split(value, ".") + if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') { + return false + } + for _, part := range parts { + for _, ch := range part { + if ch < '0' || ch > '9' { + return false + } + } + } + return strings.Trim(parts[0]+parts[1], "0") != "" +} + func required(lookup func(string) (string, bool), name string) (string, error) { value, present := lookup(name) if !present || strings.TrimSpace(value) == "" { diff --git a/admin/internal/config/config_test.go b/admin/internal/config/config_test.go index f321d00..8c53190 100644 --- a/admin/internal/config/config_test.go +++ b/admin/internal/config/config_test.go @@ -21,6 +21,9 @@ func TestLoad(t *testing.T) { "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), "CMBUYER_COOKIE_SECURE": "true", "CMBUYER_DATABASE_SOURCE": ":memory:", + "CMBUYER_AUTHORIZATION_TTL": "10m", + "CMBUYER_MAX_TASK_QUANTITY": "99", + "CMBUYER_MAX_TOTAL_PRICE": "999.99", } got, err := config.Load(lookup(values)) @@ -43,6 +46,9 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) { "CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash), "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), "CMBUYER_DATABASE_SOURCE": ":memory:", + "CMBUYER_AUTHORIZATION_TTL": "10m", + "CMBUYER_MAX_TASK_QUANTITY": "99", + "CMBUYER_MAX_TOTAL_PRICE": "999.99", } tests := []struct { @@ -55,6 +61,9 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) { {"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"}, {"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"}, + {"invalid authorization ttl", func(values map[string]string) { values["CMBUYER_AUTHORIZATION_TTL"] = "0s" }, "CMBUYER_AUTHORIZATION_TTL"}, + {"invalid maximum quantity", func(values map[string]string) { values["CMBUYER_MAX_TASK_QUANTITY"] = "0" }, "CMBUYER_MAX_TASK_QUANTITY"}, + {"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"}, } for _, test := range tests { diff --git a/admin/internal/server/router.go b/admin/internal/server/router.go index e574a73..f1beb06 100644 --- a/admin/internal/server/router.go +++ b/admin/internal/server/router.go @@ -2,11 +2,16 @@ package server import ( + "bytes" "crypto/subtle" + "encoding/json" "errors" + "io" + "mime" "net/http" "net/url" "strings" + "unicode/utf8" "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/tasks" @@ -17,6 +22,7 @@ import ( ) const maxFormBytes = 8 << 10 +const maxJSONBytes = 64 << 10 // Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。 type Options struct { @@ -42,10 +48,84 @@ func NewRouter(options Options) (*gin.Engine, error) { router.GET("/tasks", tasksPage(options)) router.GET("/tasks/new", newTaskPage(options)) router.POST("/tasks", createTask(options)) + router.POST("/tasks/start-purchases", startPurchases(options)) + router.GET("/static/tasks.js", func(context *gin.Context) { + context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript()) + }) return router, nil } +func startPurchases(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + if !options.Sessions.IsAuthenticated(context.Request) { + context.Status(http.StatusUnauthorized) + return + } + authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, context.GetHeader("X-CSRF-Token")) + if !authenticated || !csrfOK { + context.Status(http.StatusForbidden) + return + } + if !isJSONContentType(context.GetHeader("Content-Type")) { + context.Status(http.StatusUnsupportedMediaType) + return + } + context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxJSONBytes) + raw, err := io.ReadAll(context.Request.Body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + context.Status(http.StatusRequestEntityTooLarge) + } else { + context.Status(http.StatusBadRequest) + } + return + } + if !utf8.Valid(raw) { + context.Status(http.StatusBadRequest) + return + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var command tasks.StartCommand + if err := decoder.Decode(&command); err != nil { + context.Status(http.StatusBadRequest) + return + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + context.Status(http.StatusBadRequest) + return + } + result, err := options.Tasks.StartPurchases(context.Request.Context(), command, options.AdminUsername) + if err != nil { + if errors.Is(err, tasks.ErrInvalidStart) { + context.Status(http.StatusBadRequest) + } else if errors.Is(err, tasks.ErrStartConflict) { + context.Status(http.StatusConflict) + } else { + context.Status(http.StatusInternalServerError) + } + return + } + context.JSON(http.StatusOK, result) + } +} + +func isJSONContentType(value string) bool { + mediaType, parameters, err := mime.ParseMediaType(value) + if err != nil || mediaType != "application/json" { + return false + } + for name, value := range parameters { + if name != "charset" || !strings.EqualFold(value, "utf-8") { + return false + } + } + return true +} + func healthz(context *gin.Context) { context.JSON(http.StatusOK, gin.H{"status": "ok"}) } @@ -55,7 +135,7 @@ func securityHeaders() gin.HandlerFunc { context.Header("Cache-Control", "no-store") context.Header("X-Content-Type-Options", "nosniff") context.Header("Referrer-Policy", "no-referrer") - context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") context.Next() } } @@ -126,14 +206,23 @@ func tasksPage(options Options) gin.HandlerFunc { return } - drafts, err := options.Tasks.ListDrafts(context.Request.Context()) + filter := tasks.TaskFilter{Keyword: context.Query("keyword"), Status: context.Query("status"), CreatedFrom: context.Query("created_from"), CreatedTo: context.Query("created_to")} + if validation := tasks.ValidateTaskFilter(filter); !validation.Valid() { + startKey, err := tasks.NewCreateKey() + if err != nil { + context.Status(http.StatusInternalServerError) + return + } + renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfToken, Filter: filter, FilterErrors: validation, HasFilter: true, StartKey: startKey}) + return + } + data, err := taskListData(context, options, csrfToken, filter) if err != nil { context.Status(http.StatusInternalServerError) return } - data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts} - for _, draft := range drafts { - if draft.ID == context.Query("created") { + for _, row := range data.Tasks { + if row.ID == context.Query("created") { data.Success = true break } @@ -185,24 +274,32 @@ func createTask(options Options) gin.HandlerFunc { } fullPage := requestForm.Get("form_mode") == "full" if !validation.Valid() { - drafts, err := options.Tasks.ListDrafts(context.Request.Context()) - if err != nil { - context.Status(http.StatusInternalServerError) + data, ok := createErrorData(context, options, fullPage) + if !ok { return } - renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) + data.Form = form + data.Errors = validation + data.OpenForm = !fullPage + data.FullPage = fullPage + data.FocusField = firstError(validation) + renderTasks(context, http.StatusBadRequest, data) return } created, err := options.Tasks.CreateDraft(context.Request.Context(), draft) if err != nil { if errors.Is(err, tasks.ErrCreateKeyConflict) { validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。" - drafts, listErr := options.Tasks.ListDrafts(context.Request.Context()) - if listErr != nil { - context.Status(http.StatusInternalServerError) + data, ok := createErrorData(context, options, fullPage) + if !ok { return } - renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) + data.Form = form + data.Errors = validation + data.OpenForm = !fullPage + data.FullPage = fullPage + data.FocusField = firstError(validation) + renderTasks(context, http.StatusConflict, data) return } context.Status(http.StatusInternalServerError) @@ -226,6 +323,38 @@ func csrfFor(context *gin.Context, options Options) string { csrf, _ := options.Sessions.Ensure(context.Writer, context.Request) return csrf } + +func taskListData(context *gin.Context, options Options, csrfToken string, filter tasks.TaskFilter) (webui.TasksData, error) { + rows, err := options.Tasks.ListTasks(context.Request.Context(), filter) + if err != nil { + return webui.TasksData{}, err + } + startKey, err := tasks.NewCreateKey() + if err != nil { + return webui.TasksData{}, err + } + return webui.TasksData{ + CSRFToken: csrfToken, + Tasks: rows, + Filter: filter, + HasFilter: filter.Keyword != "" || filter.Status != "" || filter.CreatedFrom != "" || filter.CreatedTo != "", + StartKey: startKey, + }, nil +} + +func createErrorData(context *gin.Context, options Options, fullPage bool) (webui.TasksData, bool) { + csrfToken := csrfFor(context, options) + if fullPage { + return webui.TasksData{CSRFToken: csrfToken}, true + } + data, err := taskListData(context, options, csrfToken, tasks.TaskFilter{}) + if err != nil { + context.Status(http.StatusInternalServerError) + return webui.TasksData{}, false + } + return data, true +} + func renderTasks(context *gin.Context, status int, data webui.TasksData) { context.Header("Content-Type", "text/html; charset=utf-8") context.Status(status) diff --git a/admin/internal/server/router_test.go b/admin/internal/server/router_test.go index e4547cb..c85d915 100644 --- a/admin/internal/server/router_test.go +++ b/admin/internal/server/router_test.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "testing" + "time" "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/server" @@ -190,7 +191,7 @@ func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) { if fullPage.Code != http.StatusOK { t.Fatalf("GET full form status = %d, want 200", fullPage.Code) } - for _, want := range []string{`