96 lines
2.7 KiB
Go
96 lines
2.7 KiB
Go
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 —— 双提交方案要让页面把值填进表单。
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: csrfCookieName, Value: token, Path: "/", HttpOnly: false,
|
||
Secure: requestIsHTTPS(c.Request), SameSite: http.SameSiteLaxMode,
|
||
MaxAge: 12 * 3600,
|
||
})
|
||
}
|
||
// 交给模板渲染成隐藏字段
|
||
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 ""
|
||
}
|