This commit is contained in:
parent
d062f5a380
commit
4a927a32a3
@ -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) {
|
||||
|
||||
148
backend/controllers/wechatcallback/page.css
Normal file
148
backend/controllers/wechatcallback/page.css
Normal 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;
|
||||
}
|
||||
}
|
||||
46
backend/controllers/wechatcallback/page.go
Normal file
46
backend/controllers/wechatcallback/page.go
Normal 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())
|
||||
}
|
||||
40
backend/controllers/wechatcallback/page.html
Normal file
40
backend/controllers/wechatcallback/page.html
Normal 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>
|
||||
72
backend/controllers/wechatcallback/page_test.go
Normal file
72
backend/controllers/wechatcallback/page_test.go
Normal 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("仅允许内嵌样式的固定摘要,不应放开任意内联内容")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user