漏掉的四处(都实测确认过):
- PWA manifest 的主图标是 /web-icons/dashy-logo.png(512x512),以及
dashy-pwa_{48,72,96,144,192,512}.png —— 全部仍是上游 Dashy 品牌,
浏览器「安装到桌面/主屏」时用的就是这批。由 favicon.svg 重新派生。
- portal/404.html、500.html 没有任何 icon 标签(浏览器只能退化去找
/favicon.ico)。实测两条路径都能取到图标,但仍显式声明更稳妥。
- 登录页模板(admin-server/auth.go)同样缺 icon 标签。
所有派生 PNG 都逐个解码验证(颜色数 111~1603,非空非纯色)。
262 lines
11 KiB
Go
262 lines
11 KiB
Go
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>
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||
<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)
|
||
}
|