Files
cmautobuy/admin/handler/web/csrf.go
T
chengmaandClaude Opus 5 6ae768463f feat: 实现客户端注册与任务领取接口
Admin 的第一个业务功能。选它打头是因为它是穿透所有层的最薄一条竖切
(HTTP → handler/api → service → repository → SQLite → handler/web → 页面),
一个工单把分层模式立起来,后面四个模块照抄;同时它是与 Client 联调的接口,
能解锁另一条并行的工作线。

实现
- POST /tasks/claim:注册 + 领取。注册就在这里做,没有单独的注册接口,
  也没有心跳(理由见 docs/admin/04-client-api.md §3)
- 领取用条件更新 + 检查影响行数防并发,SQLite 没有 SELECT FOR UPDATE
- 客户端列表页:查询、按名称搜索、批量删除
- 在线状态是**算出来的**(last_seen_at 在 10 分钟内),数据库里没有该字段
- CSRF 中间件:双提交 Cookie,手写 82 行不引依赖。
  **只挂页面路由**,/api/v1/client/* 不能加——Client 不是浏览器、没有 Cookie
- 14 个单元测试

修复一个真 bug:PRAGMA 必须写进 DSN
并发领取测试报 database is locked (SQLITE_BUSY)。根因是
PRAGMA busy_timeout 每连接生效,而 database/sql 是连接池——
db.Exec("PRAGMA ...") 只作用于当时那条连接,池子新开的连接没执行过。
单线程正常、一并发就炸。改成 DSN 传参后并发测试跑 20 次全过。
这个坑已写进 docs/admin/03-data-model.md §2.1。

与工单的两处差异
- 去掉 name_is_custom 列后,"人工改的名字不被覆盖"改用更简单的做法:
  ON CONFLICT DO UPDATE SET 里不含 name,即只在首次注册时写入。
  效果相同,零额外字段、零迁移。已同步 04 §3
- 验收项"不向 dry_run 客户端分配真实下单任务"**未实现**:
  tasks 表没有字段标记任务是否需要真实下单。当前真实下单开关默认关闭、
  MVP 全是演练模式,暂不出问题,但开真实下单前必须补该字段,需另开工单

已验证(Go 1.23.0)
- go vet / gofmt / go test 全过,并发测试重复 20 次稳定通过
- 端到端:无任务 claim 204;插入任务后 claim 200 且 payload 含
  goods_url/goods_id/options/quantity/max_price_cent、无租约无 Admin 状态;
  重复 claim 204;缺 X-Client-Id 400;POST 无 CSRF token 403;
  列表页两台客户端在线状态与统计正确

说明:Gitea 尚未配置,本次无对应工单号。
submit_result / submit_failure 及其幂等处理留给下一个工单。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:56:18 +08:00

93 lines
2.7 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 web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
)
// CSRF 防护,用的是「双提交 Cookie」这个最简单的方案:
//
// 1. 给浏览器种一个随机 token 的 Cookie;
// 2. 每个表单里带一个同值的隐藏字段;
// 3. 提交时比对两者,不一致就拒绝。
//
// 原理:攻击者的页面可以诱导浏览器带上 Cookie 发请求,
// 但**读不到** Cookie 的值,所以拼不出正确的隐藏字段。
//
// 没有引第三方库,是因为这个方案本身就几十行,
// 而且多一个依赖就多一个可能要求 Go >= 1.25 的风险。
//
// **只给页面路由用。** 给 Client 的 /api/v1/client/* 绝不能加——
// 它不是浏览器、没有 Cookie,加了会直接把它挡在门外。
const (
csrfCookieName = "cmautobuy_csrf"
csrfFieldName = "csrf_token"
csrfTokenBytes = 32
)
// CSRFMiddleware 返回 Gin 中间件。
//
// GET 等安全方法只负责发 token;POST 等写操作要校验。
func CSRFMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(csrfCookieName)
if err != nil || token == "" {
token, err = newCSRFToken()
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
// HttpOnly 必须为 false —— 双提交方案要让页面把值填进表单。
// 本项目只监听本机,Secure 先留 false,将来上 HTTPS 再打开。
c.SetCookie(csrfCookieName, token, 12*3600, "/", "", false, false)
}
// 交给模板渲染成隐藏字段
c.Set(csrfFieldName, token)
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
c.Next()
return
}
submitted := c.PostForm(csrfFieldName)
if submitted == "" {
submitted = c.GetHeader("X-CSRF-Token")
}
// 用常数时间比较,避免通过响应快慢猜 token
if subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 {
c.HTML(http.StatusForbidden, "partials/error", gin.H{
"Title": "请求被拒绝",
"Message": "表单校验失败(CSRF token 不匹配)。" +
"通常是页面开太久过期了,返回上一页刷新后重试即可。",
})
c.Abort()
return
}
c.Next()
}
}
// newCSRFToken 生成一个随机 token。
func newCSRFToken() (string, error) {
buf := make([]byte, csrfTokenBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// csrfToken 从上下文取出当前 token,供页面渲染隐藏字段。
func csrfToken(c *gin.Context) string {
if v, ok := c.Get(csrfFieldName); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}