From 8cc8574e8a63d107891adf15cb5f0412693a8412 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 02:46:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=87=86=E5=A4=87=20Admin=20=E7=BA=BF?= =?UTF-8?q?=E4=B8=8A=20HTTPS=20=E9=83=A8=E7=BD=B2=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/deploy/cmautobuy-admin.service | 27 +++++++++++++++++++++++ admin/deploy/nginx-cmautobuy.conf.example | 26 ++++++++++++++++++++++ admin/handler/web/auth.go | 4 ++-- admin/handler/web/auth_test.go | 20 ++++++++++++----- admin/handler/web/csrf.go | 7 ++++-- admin/handler/web/proxy.go | 26 ++++++++++++++++++++++ 6 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 admin/deploy/cmautobuy-admin.service create mode 100644 admin/deploy/nginx-cmautobuy.conf.example create mode 100644 admin/handler/web/proxy.go diff --git a/admin/deploy/cmautobuy-admin.service b/admin/deploy/cmautobuy-admin.service new file mode 100644 index 0000000..d808cbb --- /dev/null +++ b/admin/deploy/cmautobuy-admin.service @@ -0,0 +1,27 @@ +[Unit] +Description=CM AutoBuy Admin +After=network-online.target mysql84.service +Wants=network-online.target +Requires=mysql84.service + +[Service] +Type=simple +User=cmautobuy +Group=cmautobuy +WorkingDirectory=/opt/cmautobuy +EnvironmentFile=/etc/cmautobuy/admin.env +ExecStart=/opt/cmautobuy/cmautobuy-admin -addr 127.0.0.1:18080 +Restart=on-failure +RestartSec=5s +TimeoutStopSec=30s +UMask=0077 + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/cmautobuy/data +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/admin/deploy/nginx-cmautobuy.conf.example b/admin/deploy/nginx-cmautobuy.conf.example new file mode 100644 index 0000000..c38661b --- /dev/null +++ b/admin/deploy/nginx-cmautobuy.conf.example @@ -0,0 +1,26 @@ +# 将域名和证书路径替换为实际值后放入服务器 Nginx 配置目录。 +# Admin 本身只监听 127.0.0.1:18080,不直接开放公网端口。 +server { + listen 80; + server_name admin.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name admin.example.com; + + ssl_certificate /path/to/fullchain.pem; + ssl_certificate_key /path/to/privkey.pem; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/admin/handler/web/auth.go b/admin/handler/web/auth.go index 27ad0a9..d4d4db5 100644 --- a/admin/handler/web/auth.go +++ b/admin/handler/web/auth.go @@ -202,7 +202,7 @@ func safeNext(raw string) string { func setAuthCookie(c *gin.Context, token string, expiresAt time.Time) { http.SetCookie(c.Writer, &http.Cookie{ Name: authCookieName, Value: token, Path: "/", HttpOnly: true, - Secure: c.Request.TLS != nil, SameSite: http.SameSiteLaxMode, + Secure: requestIsHTTPS(c.Request), SameSite: http.SameSiteLaxMode, Expires: expiresAt, MaxAge: int(service.WebSessionDuration.Seconds()), }) } @@ -210,7 +210,7 @@ func setAuthCookie(c *gin.Context, token string, expiresAt time.Time) { func clearAuthCookie(c *gin.Context) { http.SetCookie(c.Writer, &http.Cookie{ Name: authCookieName, Value: "", Path: "/", HttpOnly: true, - Secure: c.Request.TLS != nil, SameSite: http.SameSiteLaxMode, + Secure: requestIsHTTPS(c.Request), SameSite: http.SameSiteLaxMode, Expires: time.Unix(1, 0), MaxAge: -1, }) } diff --git a/admin/handler/web/auth_test.go b/admin/handler/web/auth_test.go index bf62cd9..92da55b 100644 --- a/admin/handler/web/auth_test.go +++ b/admin/handler/web/auth_test.go @@ -40,12 +40,16 @@ func TestPasswordReturnPath移除改密反馈参数(t *testing.T) { func TestAuthCookie安全属性(t *testing.T) { gin.SetMode(gin.TestMode) for _, test := range []struct { - name string - tls bool - secure bool + name string + tls bool + remoteAddress string + forwarded string + secure bool }{ - {"HTTP", false, false}, - {"HTTPS", true, true}, + {name: "HTTP", secure: false}, + {name: "HTTPS", tls: true, secure: true}, + {name: "本机HTTPS反向代理", remoteAddress: "127.0.0.1:12345", forwarded: "https", secure: true}, + {name: "外部来源不能伪造代理头", remoteAddress: "203.0.113.8:12345", forwarded: "https", secure: false}, } { t.Run(test.name, func(t *testing.T) { response := httptest.NewRecorder() @@ -53,6 +57,12 @@ func TestAuthCookie安全属性(t *testing.T) { if test.tls { request.TLS = &tls.ConnectionState{} } + if test.remoteAddress != "" { + request.RemoteAddr = test.remoteAddress + } + if test.forwarded != "" { + request.Header.Set("X-Forwarded-Proto", test.forwarded) + } context, _ := gin.CreateTestContext(response) context.Request = request setAuthCookie(context, "raw-token", time.Now().Add(12*time.Hour)) diff --git a/admin/handler/web/csrf.go b/admin/handler/web/csrf.go index 0c2f2b1..96251f8 100644 --- a/admin/handler/web/csrf.go +++ b/admin/handler/web/csrf.go @@ -42,8 +42,11 @@ func CSRFMiddleware() gin.HandlerFunc { return } // HttpOnly 必须为 false —— 双提交方案要让页面把值填进表单。 - // 本项目只监听本机,Secure 先留 false,将来上 HTTPS 再打开。 - c.SetCookie(csrfCookieName, token, 12*3600, "/", "", false, 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) diff --git a/admin/handler/web/proxy.go b/admin/handler/web/proxy.go new file mode 100644 index 0000000..066999e --- /dev/null +++ b/admin/handler/web/proxy.go @@ -0,0 +1,26 @@ +package web + +import ( + "net" + "net/http" + "strings" +) + +// requestIsHTTPS 识别直连 TLS,或仅信任本机反向代理传入的 HTTPS 标记。 +// Admin 生产进程只监听 127.0.0.1,Nginx 在同机终止 TLS;不能无条件信任 +// X-Forwarded-Proto,否则直接访问端口的客户端可以伪造安全来源。 +func requestIsHTTPS(request *http.Request) bool { + if request.TLS != nil { + return true + } + host, _, err := net.SplitHostPort(request.RemoteAddr) + if err != nil { + return false + } + remoteIP := net.ParseIP(host) + if remoteIP == nil || !remoteIP.IsLoopback() { + return false + } + forwardedProto := strings.TrimSpace(strings.Split(request.Header.Get("X-Forwarded-Proto"), ",")[0]) + return strings.EqualFold(forwardedProto, "https") +}