Files
HomeDash/admin-server/auth.go
HomeDash Bot 3d18fb59ce fix(ui): 登录页页脚被当纯文本显示(html/template 转义)+ 三处页脚统一
用户发现登录页出现 `<div class="navi-footer">…` 这串标记文字。

① 根因:auth.go 把 beianFooter() 的字符串塞进 Go html/template(map[string]string),
   模板自动转义整段 HTML → 页面上直接显示标记源码。
   修法:改用 map[string]any + template.HTML(beianFooter 内部已对备案号做 html.EscapeString,
   注入合法)。
② 登录页与门户页此前**都无视**后台的「页脚」自定义字段(后台提示语写的是"填写后以这里为准")。
   新增统一入口 siteFooter(info):自定义 footer 优先,否则按 icp/gongan/座右铭生成;
   pages.go(门户页)与 auth.go(登录页)都改走它。
③ 页脚座右铭此前只由 navi.js 在 Dashy 页客户端注入 → 登录页/门户页永远没有。
   新增 pageInfo 字段 motto(换行或 | 分行,留空用内置默认),
   footerMarkup() 生成时一并输出 .navi-quote 两行;navi.js 已有幂等判断不会重复注入。
   后台「站点信息」新增座右铭输入框,并登记进 /api/schema。
④ 登录页 / 门户页补页脚样式:左列两行备案号、右列两行座右铭;窄屏退化为居中单列。
2026-09-13 07:56:38 +08:00

260 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const portalCookieName = "portal_session"
var (
portalUsername = envOr("PORTAL_USERNAME", "admin")
portalPassword = envOr("PORTAL_PASSWORD", "changeme")
// 均不预置真实域名:cookie 域为空时下发 host-only cookie(同一站点可用,
// 跨子域单点登录请设置 PORTAL_COOKIE_DOMAIN=.example.com);
// 门户首页为空时用相对跳转 "/"。
portalCookieDomain = envOr("PORTAL_COOKIE_DOMAIN", "")
portalHomeURL = envOr("PORTAL_HOME_URL", "/")
portalSessionTTL = 24 * time.Hour
portalSecretOnce sync.Once
portalSecret []byte
)
type portalClaims struct {
User string `json:"u"`
Expiry int64 `json:"e"`
}
func sessionSecret() []byte {
portalSecretOnce.Do(func() {
if raw := strings.TrimSpace(os.Getenv("PORTAL_SESSION_SECRET")); raw != "" {
sum := sha256.Sum256([]byte(raw))
portalSecret = sum[:]
return
}
path := filepath.Join(filepath.Dir(confFile), "portal-session.key")
if b, err := os.ReadFile(path); err == nil {
if decoded, err := hex.DecodeString(strings.TrimSpace(string(b))); err == nil && len(decoded) >= 32 {
portalSecret = decoded
return
}
}
portalSecret = make([]byte, 32)
if _, err := rand.Read(portalSecret); err != nil {
sum := sha256.Sum256([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid())))
portalSecret = sum[:]
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte(hex.EncodeToString(portalSecret)), 0o600)
})
return portalSecret
}
func signClaims(c portalClaims) (string, error) {
payload, err := json.Marshal(c)
if err != nil {
return "", err
}
body := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, sessionSecret())
_, _ = mac.Write([]byte(body))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return body + "." + sig, nil
}
func verifyClaims(token string) bool {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return false
}
mac := hmac.New(sha256.New, sessionSecret())
_, _ = mac.Write([]byte(parts[0]))
got, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(got, mac.Sum(nil)) {
return false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false
}
var c portalClaims
if json.Unmarshal(payload, &c) != nil {
return false
}
// 用户名可能已被后台改过,按当前配置比较,避免改完账号旧会话仍有效
user, _ := portalAuthConfigured()
return c.User == user && c.Expiry >= time.Now().Unix()
}
func portalAuthenticated(r *http.Request) bool {
c, err := r.Cookie(portalCookieName)
return err == nil && verifyClaims(c.Value)
}
func setPortalCookie(w http.ResponseWriter, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: portalCookieName, Value: token, Path: "/", Domain: portalCookieDomain,
MaxAge: maxAge, Expires: time.Now().Add(time.Duration(maxAge) * time.Second),
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
}
func secureEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func safePortalRedirect(raw string) string {
if strings.TrimSpace(raw) == "" {
return portalHomeURL
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" {
return portalHomeURL
}
cfg, err := loadGateway()
if err != nil {
return portalHomeURL
}
for _, r := range cfg.Routes {
if strings.EqualFold(strings.TrimSpace(r.Host), u.Hostname()) {
return u.String()
}
}
return portalHomeURL
}
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}} - 登录</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{margin-top:22px;font-size:11px;color:var(--tx2)}
.foot a{color:inherit;text-decoration:none}
/* 页脚:左列两行备案号 / 右列两行座右铭(与主站一致)。
登录卡片只有 400px,窄到放不下两列时退化为居中单列,避免挤压换行。 */
.foot .navi-footer{display:grid;grid-template-columns:auto auto;grid-template-rows:auto auto;grid-auto-flow:column;gap:2px 12px;justify-content:space-between;align-items:center;text-align:left}
.foot .navi-footer-sep{display:none}
.foot .navi-quote{grid-column:2;justify-self:end;opacity:.85}
@media(max-width:430px){.foot .navi-footer{grid-template-columns:auto;grid-auto-flow:row;justify-content:center;text-align:center;gap:2px}
.foot .navi-quote{grid-column:1;justify-self:center}}
@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">{{.Logo}}</div><h1>{{.Title}}</h1><p class="sub">{{.Subtitle}}</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">{{if .Footer}}{{.Footer}}{{end}}</div></main></body></html>`))
func renderPortalLogin(w http.ResponseWriter, redirect, errText string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
title, subtitle, logo, _, _ := authPageInfo()
// 关键:页脚是一段 HTML,必须用 template.HTML 交给模板。
// 此前用 map[string]string,html/template 会把整段标记转义成文本,
// 页面直接显示 <div class="navi-footer">… 源码(线上实测)。
_ = portalLoginTemplate.Execute(w, map[string]any{
"Redirect": url.QueryEscape(redirect), "Error": errText,
"Title": title, "Subtitle": subtitle, "Logo": logo,
"Footer": template.HTML(siteFooter(pageInfoMap())),
})
}
func handlePortalLogin(w http.ResponseWriter, r *http.Request) {
redirect := safePortalRedirect(r.URL.Query().Get("redirect"))
if r.Method == http.MethodGet {
if portalAuthenticated(r) {
http.Redirect(w, r, redirect, http.StatusFound)
return
}
renderPortalLogin(w, redirect, "")
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
renderPortalLogin(w, redirect, "请求解析失败")
return
}
if !checkPortalCredentials(r.FormValue("username"), r.FormValue("password")) {
w.WriteHeader(http.StatusUnauthorized)
renderPortalLogin(w, redirect, "用户名或密码错误")
return
}
user, _ := portalAuthConfigured()
token, err := signClaims(portalClaims{User: user, Expiry: time.Now().Add(portalSessionTTL).Unix()})
if err != nil {
http.Error(w, "session error", http.StatusInternalServerError)
return
}
setPortalCookie(w, token, int(portalSessionTTL/time.Second))
http.Redirect(w, r, redirect, http.StatusFound)
}
func handlePortalVerify(w http.ResponseWriter, r *http.Request) {
if !portalAuthenticated(r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusOK)
}
func handlePortalLogout(w http.ResponseWriter, r *http.Request) {
setPortalCookie(w, "", -1)
http.Redirect(w, r, "/login", http.StatusFound)
}
func handlePortalRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if portalAuthenticated(r) {
http.Redirect(w, r, portalHomeURL, http.StatusFound)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}
func registerPortalAuth() {
http.HandleFunc("/login", handlePortalLogin)
http.HandleFunc("/verify", handlePortalVerify)
http.HandleFunc("/_auth", handlePortalVerify)
http.HandleFunc("/logout", handlePortalLogout)
http.HandleFunc("/auth/verify", handlePortalVerify)
}