fix(auth): 登录页恢复自动深浅色(我在重写模板时造成的回归)
原 portal-auth 的登录页有 prefers-color-scheme 检测 + 手动切换 + localStorage 记忆;我把它换成内置登录服务时只写了亮色,自动深浅色因此消失。 - 默认跟随系统(prefers-color-scheme),手动切换后写入 localStorage - 深浅两套 CSS 变量(背景/卡片/边框/文字/输入框/错误提示/阴影) - 右上角切换按钮,补 aria-label - 加 color-scheme + prefers-reduced-motion - 测试:断言主题检测、切换按钮、no-store;并断言 redirect 被转义(注入防护)
This commit is contained in:
@ -139,9 +139,32 @@ func safePortalRedirect(raw string) string {
|
||||
|
||||
var portalLoginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>统一门户登录</title><style>
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color:#172033;background:radial-gradient(900px 520px at 15% 0,#dbeafe,transparent 62%),radial-gradient(700px 500px at 90% 10%,#ede9fe,transparent 58%),#f8fafc}.card{width:min(400px,100%);padding:38px;border:1px solid rgba(15,23,42,.1);border-radius:20px;background:rgba(255,255,255,.82);box-shadow:0 24px 70px rgba(15,23,42,.14);backdrop-filter:blur(16px)}.logo{width:52px;height:52px;display:grid;place-items:center;margin:0 auto 15px;border-radius:15px;color:#2563eb;background:#dbeafe;font-weight:900;font-size:21px}h1{text-align:center;font-size:22px;margin:0 0 5px}.sub{text-align:center;color:#64748b;font-size:13px;margin:0 0 25px}label{display:block;font-size:12px;font-weight:700;color:#475569;margin:13px 0 6px}input{width:100%;padding:11px 13px;border:1px solid #d9e1ec;border-radius:10px;background:#f8fafc;font:inherit;font-size:15px;outline:none}input:focus{border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.12)}button{width:100%;margin-top:18px;padding:11px;border:0;border-radius:10px;background:#2563eb;color:#fff;font:inherit;font-weight:750;cursor:pointer}.err{padding:9px 11px;border-radius:9px;background:#fef2f2;color:#b91c1c;font-size:13px;margin-bottom:14px}.foot{text-align:center;margin-top:22px;font-size:11px;color:#94a3b8}.foot a{color:inherit;text-decoration:none}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
||||
</style></head><body><main class="card"><div class="logo">JF</div><h1>统一门户</h1><p class="sub">登录后访问受保护的内部服务</p>{{if .Error}}<div class="err">{{.Error}}</div>{{end}}<form method="post" action="/login?redirect={{.Redirect}}"><label for="u">用户名</label><input id="u" name="username" autocomplete="username" required autofocus><label for="p">密码</label><input id="p" type="password" name="password" autocomplete="current-password" required><button type="submit">登录</button></form><div class="foot"><a href="https://beian.miit.gov.cn" target="_blank" rel="noopener">京ICP备00000000号-1</a></div></main></body></html>`))
|
||||
<title>统一门户登录</title>
|
||||
<script>(function(){var s=localStorage.getItem('theme');var p=window.matchMedia('(prefers-color-scheme:dark)').matches;document.documentElement.setAttribute('data-theme',s||(p?'dark':'light'))})()</script>
|
||||
<style>
|
||||
/* 深浅色:默认跟随系统(prefers-color-scheme),手动切换后写入 localStorage。
|
||||
原 portal-auth 的登录页就是这个行为,重写模板时被我漏掉了 —— 这里补回来。 */
|
||||
:root,[data-theme="light"]{--bg:#f8fafc;--card:rgba(255,255,255,.82);--bd:rgba(15,23,42,.1);--tx:#172033;--tx2:#64748b;--in:#f8fafc;--inbd:#d9e1ec;--ac:#2563eb;--errbg:#fef2f2;--errtx:#b91c1c;--shadow:0 24px 70px rgba(15,23,42,.14);--logo:#dbeafe}
|
||||
[data-theme="dark"]{--bg:#0b1120;--card:rgba(23,32,51,.86);--bd:rgba(148,163,184,.18);--tx:#e8eefc;--tx2:#94a3b8;--in:#151d2e;--inbd:#2b3750;--ac:#60a5fa;--errbg:#3b1a1a;--errtx:#fca5a5;--shadow:0 24px 70px rgba(0,0,0,.5);--logo:#1e3a5f}
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--tx);background:radial-gradient(900px 520px at 15% 0,rgba(37,99,235,.18),transparent 62%),radial-gradient(700px 500px at 90% 10%,rgba(139,92,246,.16),transparent 58%),var(--bg);color-scheme:light dark;transition:background .2s}
|
||||
.card{position:relative;width:min(400px,100%);padding:38px;border:1px solid var(--bd);border-radius:20px;background:var(--card);box-shadow:var(--shadow);backdrop-filter:blur(16px)}
|
||||
.theme{position:absolute;top:12px;right:12px;width:34px;height:34px;border:1px solid var(--bd);border-radius:9px;background:transparent;color:var(--tx2);cursor:pointer;font-size:15px;line-height:1}
|
||||
.theme:hover{color:var(--ac);border-color:var(--ac)}
|
||||
.logo{width:52px;height:52px;display:grid;place-items:center;margin:0 auto 15px;border-radius:15px;color:var(--ac);background:var(--logo);font-weight:900;font-size:21px}
|
||||
h1{text-align:center;font-size:22px;margin:0 0 5px}.sub{text-align:center;color:var(--tx2);font-size:13px;margin:0 0 25px}
|
||||
label{display:block;font-size:12px;font-weight:700;color:var(--tx2);margin:13px 0 6px}
|
||||
input{width:100%;padding:11px 13px;border:1px solid var(--inbd);border-radius:10px;background:var(--in);color:var(--tx);font:inherit;font-size:15px;outline:none}
|
||||
input:focus{border-color:var(--ac);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
|
||||
button[type=submit]{width:100%;margin-top:18px;padding:11px;border:0;border-radius:10px;background:var(--ac);color:#fff;font:inherit;font-weight:750;cursor:pointer}
|
||||
.err{padding:9px 11px;border-radius:9px;background:var(--errbg);color:var(--errtx);font-size:13px;margin-bottom:14px}
|
||||
.foot{text-align:center;margin-top:22px;font-size:11px;color:var(--tx2)}.foot a{color:inherit;text-decoration:none}
|
||||
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
</style></head><body><main class="card">
|
||||
<button class="theme" type="button" onclick="(function(){var n=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',n);localStorage.setItem('theme',n)})()" title="切换深浅色" aria-label="切换深浅色">◐</button>
|
||||
<div class="logo">JF</div><h1>统一门户</h1><p class="sub">登录后访问受保护的内部服务</p>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/login?redirect={{.Redirect}}"><label for="u">用户名</label><input id="u" name="username" autocomplete="username" required autofocus><label for="p">密码</label><input id="p" type="password" name="password" autocomplete="current-password" required><button type="submit">登录</button></form>
|
||||
<div class="foot"><a href="https://beian.miit.gov.cn" target="_blank" rel="noopener">京ICP备00000000号-1</a></div></main></body></html>`))
|
||||
|
||||
func renderPortalLogin(w http.ResponseWriter, redirect, errText string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
54
admin-server/auth_test.go
Normal file
54
admin-server/auth_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 登录页必须跟随系统深浅色,并提供手动切换(原 portal-auth 的行为,
|
||||
// 重写模板时曾漏掉)。
|
||||
func TestLoginPageThemeSupport(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
renderPortalLogin(rec, "https://dashy.example.com/", "")
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"prefers-color-scheme:dark", "[data-theme=\"dark\"]", "localStorage.setItem('theme'",
|
||||
"toggle", "aria-label",
|
||||
} {
|
||||
if want == "toggle" {
|
||||
if !strings.Contains(body, "切换深浅色") {
|
||||
t.Fatalf("缺少手动切换按钮")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("登录页缺少 %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("Content-Type 不对")
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("登录页不应被缓存")
|
||||
}
|
||||
}
|
||||
|
||||
// redirect 必须被 HTML 转义后写入表单 action,避免注入。
|
||||
func TestLoginPageEscapesRedirect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldConf, oldURL := confFile, historyBaseURL
|
||||
confFile, historyBaseURL = dir+"/conf.yml", "http://127.0.0.1:1"
|
||||
defer func() { confFile, historyBaseURL = oldConf, oldURL }()
|
||||
_ = os.WriteFile(confFile, []byte("sections: []\n"), 0o644)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/login?redirect=https://evil.example/\"><script>x</script>", nil)
|
||||
handlePortalLogin(rec, req)
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "<script>x</script>") {
|
||||
t.Fatalf("redirect 未转义,存在注入:%s", body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user