优化微信授权结果页的移动端布局与安全提示
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-03 21:07:19 +08:00
parent d062f5a380
commit 4a927a32a3
5 changed files with 309 additions and 7 deletions

View File

@ -8,6 +8,7 @@ import (
"net/http"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/controllers/wechatcallback"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/hooks/framework"
@ -157,17 +158,12 @@ func WeChatBinding(ctx *appcontext.Context, w http.ResponseWriter, req *http.Req
// WeChatBindingCallback 不依赖手机浏览器的登录 Cookie,也不自动完成绑定。
func WeChatBindingCallback(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
service := hooks.PluginConfigService()
if !config.IsSetupReady() || service == nil || service.ClaimWeChatBinding(req.Context(), req.URL.Query().Get("state"), req.URL.Query().Get("code")) != nil {
http.Error(w, "授权失败或二维码已失效,请返回 Smail 页面重新扫码。", http.StatusBadRequest)
wechatcallback.Write(w, false)
return
}
_, _ = w.Write([]byte("微信授权已完成。请返回发起扫码的 Smail 页面,核对微信昵称后点击确认绑定。"))
wechatcallback.Write(w, true)
}
func TestWeChatMessage(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {

View File

@ -0,0 +1,148 @@
:root {
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #182638;
background: #f3f6fa;
-webkit-text-size-adjust: 100%;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
min-height: 100svh;
padding: 48px 20px 28px;
padding-top: max(48px, env(safe-area-inset-top));
padding-bottom: max(28px, env(safe-area-inset-bottom));
}
.result-card {
width: 100%;
max-width: 440px;
margin: 0 auto;
padding: 28px 24px 24px;
border: 1px solid #e5eaf1;
border-radius: 24px;
background: #fff;
box-shadow: 0 12px 40px rgb(32 56 85 / 5%);
text-align: center;
}
.brand {
color: #254c8a;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.3px;
}
.brand span {
margin-left: 8px;
color: #697789;
font-size: 14px;
font-weight: 400;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 32px auto 16px;
border-radius: 50%;
font-size: 38px;
font-weight: 600;
line-height: 1;
}
.success {
color: #158057;
background: #e9f7f0;
}
.warning {
color: #a15b12;
background: #fff4e3;
}
.status-label {
margin: 0 0 8px;
color: #637186;
font-size: 13px;
}
h1 {
margin: 0;
font-size: 26px;
line-height: 1.4;
font-weight: 650;
}
.description {
margin: 12px 0 24px;
color: #526174;
font-size: 15px;
line-height: 1.7;
}
.steps {
display: grid;
gap: 16px;
margin: 0;
padding: 20px 16px;
border-radius: 16px;
background: #f6f8fb;
list-style: none;
text-align: left;
}
.steps li {
display: flex;
align-items: flex-start;
gap: 12px;
font-size: 15px;
line-height: 24px;
}
.step-number {
flex: 0 0 24px;
height: 24px;
border-radius: 50%;
background: #e8eef8;
color: #355987;
font-size: 13px;
font-weight: 600;
text-align: center;
}
.notice {
margin: 20px 0;
color: #647286;
font-size: 13px;
line-height: 1.8;
}
footer {
padding-top: 18px;
border-top: 1px solid #edf0f5;
color: #68768a;
font-size: 12px;
line-height: 1.7;
}
@media (max-width: 360px) {
body {
padding: 24px 12px;
}
.result-card {
padding: 24px 16px;
}
.steps {
padding: 16px 12px;
}
}

View File

@ -0,0 +1,46 @@
package wechatcallback
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/base64"
"html/template"
"net/http"
)
// 页面随主程序发布,无需外部字体、脚本或手机浏览器登录状态。
//
//go:embed page.html
var pageHTML string
//go:embed page.css
var pageCSS string
var resultTemplate = template.Must(template.New("wechat-result").Parse(pageHTML))
// Write 只呈现授权结果,不接收身份凭据,也不执行账号绑定。
func Write(w http.ResponseWriter, authorized bool) {
digest := sha256.Sum256([]byte(pageCSS))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'sha256-"+base64.StdEncoding.EncodeToString(digest[:])+"'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
var body bytes.Buffer
data := struct {
Authorized bool
CSS template.CSS
}{authorized, template.CSS(pageCSS)} // 仅信任编译时内嵌样式,禁止传入请求内容。
if err := resultTemplate.Execute(&body, data); err != nil {
http.Error(w, "页面暂时无法显示,请返回 Smail 查看绑定状态。", http.StatusInternalServerError)
return
}
status := http.StatusOK
if !authorized {
status = http.StatusBadRequest
}
w.WriteHeader(status)
_, _ = w.Write(body.Bytes())
}

View File

@ -0,0 +1,40 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light">
<meta name="robots" content="noindex, nofollow">
<title>{{if .Authorized}}微信授权成功{{else}}微信授权未完成{{end}} · Smail</title>
<style>{{.CSS}}</style>
</head>
<body>
<main class="result-card" aria-labelledby="result-title">
<div class="brand">Smail <span>微信通知</span></div>
{{if .Authorized}}
<div class="status-icon success" aria-hidden="true">✓</div>
<p class="status-label">等待确认绑定</p>
<h1 id="result-title">微信授权成功</h1>
<p class="description">还差一步,请返回 Smail 确认绑定。</p>
<ol class="steps" aria-label="接下来的操作">
<li><span class="step-number" aria-hidden="true">1</span><span>回到发起扫码的 Smail 页面</span></li>
<li><span class="step-number" aria-hidden="true">2</span><span>核对显示的微信昵称</span></li>
<li><span class="step-number" aria-hidden="true">3</span><span>点击「确认绑定」</span></li>
</ol>
<p class="notice">本次授权不会自动绑定账号。<br>请在二维码有效期内完成确认。</p>
{{else}}
<div class="status-icon warning" aria-hidden="true">!</div>
<p class="status-label">请重新扫码</p>
<h1 id="result-title">微信授权未完成</h1>
<p class="description">二维码可能已失效,或本次授权未完成。</p>
<ol class="steps" aria-label="重新授权的操作">
<li><span class="step-number" aria-hidden="true">1</span><span>回到发起扫码的 Smail 页面</span></li>
<li><span class="step-number" aria-hidden="true">2</span><span>关闭绑定弹窗,重新生成二维码</span></li>
<li><span class="step-number" aria-hidden="true">3</span><span>使用本人微信重新扫码授权</span></li>
</ol>
<p class="notice">若已在 Smail 确认绑定,以原页面状态为准。<br>请勿刷新或重复打开旧授权链接。</p>
{{end}}
<footer>可以关闭此页面,返回原设备继续操作。</footer>
</main>
</body>
</html>

View File

@ -0,0 +1,72 @@
package wechatcallback
import (
"crypto/sha256"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWriteAuthorizationResult(t *testing.T) {
for _, test := range []struct {
name string
authorized bool
status int
message string
}{
{"authorized", true, http.StatusOK, "等待确认绑定"},
{"expired", false, http.StatusBadRequest, "请重新扫码"},
} {
t.Run(test.name, func(t *testing.T) {
response := httptest.NewRecorder()
Write(response, test.authorized)
if response.Code != test.status {
t.Fatalf("授权结果状态码错误:%d", response.Code)
}
body := response.Body.String()
for _, expected := range []string{test.message, `lang="zh-CN"`, `width=device-width, initial-scale=1`, "回到发起扫码的 Smail 页面", "<style>" + pageCSS + "</style>"} {
if !strings.Contains(body, expected) {
t.Fatalf("结果页缺少预期内容:%s", expected)
}
}
for _, forbidden := range []string{"<script", "<iframe", "<form", "http://", "https://", "#ZgotmplZ", "&#x", "绑定成功"} {
if strings.Contains(body, forbidden) {
t.Fatalf("结果页包含不应出现的内容:%s", forbidden)
}
}
if test.authorized && !strings.Contains(body, "本次授权不会自动绑定账号") {
t.Fatal("授权成功页面必须明确仍需原会话确认")
}
})
}
}
func TestWriteResultSecurityHeaders(t *testing.T) {
for _, authorized := range []bool{true, false} {
response := httptest.NewRecorder()
Write(response, authorized)
for name, expected := range map[string]string{
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
} {
if response.Header().Get(name) != expected {
t.Fatalf("结果页安全响应头不符:%s", name)
}
}
digest := sha256.Sum256([]byte(pageCSS))
policy := response.Header().Get("Content-Security-Policy")
for _, expected := range []string{"default-src 'none'", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'", "style-src 'sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + "'"} {
if !strings.Contains(policy, expected) {
t.Fatalf("结果页内容安全策略缺少约束:%s", expected)
}
}
if strings.Contains(policy, "unsafe-inline") {
t.Fatal("仅允许内嵌样式的固定摘要,不应放开任意内联内容")
}
}
}