Files
HomeDash/admin-server/auth.go
HomeDash Bot 472d8d1cd0 feat(pages): 认证页与门户页文案可配置,不再写死
需求:登录页与门户页的内容应可自定义。

实现(配置存数据卷、后台可改、留空回落内置默认,不配置行为不变):
- pages.go:pageStr 读取 pageInfo;renderPortalPage 优先数据卷
  <data>/portal/index.html 整页覆盖,否则用内置模板替换占位符;
  authPageInfo 提供登录页字段
- auth.go:登录页标题/副标题/徽标/页脚改为模板数据(含备案号)
- gateway.go:kind=portal 的首页改由 admin-server /portal-page 渲染
  (静态资源仍由 nginx 提供),使文案可配置
- portal/index.html:加入 <!--PAGE_TITLE--> / <!--HERO_TITLE--> /
  <!--HERO_SUBTITLE--> / <!--FOOTER--> 占位符
- 后台「站点信息」新增六项文案输入(含回填与保存),并提示整页覆盖方式
- README 新增「认证页与门户页文案(可配置,不写死)」含字段表与自检命令

测试:默认回落、配置取值去空白、认证页字段读取、数据卷覆盖优先、空白覆盖忽略。
2026-09-12 09:12:13 +08:00

246 lines
9.8 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")
portalCookieDomain = envOr("PORTAL_COOKIE_DOMAIN", ".example.com")
portalHomeURL = envOr("PORTAL_HOME_URL", "https://dashy.example.com")
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{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">{{.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, icp, gongan := authPageInfo()
_ = portalLoginTemplate.Execute(w, map[string]string{
"Redirect": url.QueryEscape(redirect), "Error": errText,
"Title": title, "Subtitle": subtitle, "Logo": logo,
"Footer": beianFooter(icp, gongan),
})
}
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)
}