Files
HomeDash/admin-server/auth.go
HomeDash Bot b99319b38b chore(sanitize): 备案号与域名一律可配置,仓库不再预置真实域名/备案号/内网地址
用户要求:备案号与域名都应当可配置,仓库内容要脱敏。

功能(不只是替换字面量):
- 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**;
  原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上
- 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖
- auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名,
  cookie 默认 host-only、首页默认相对路径 "/"
- gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页)
- 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL,
  避免"去掉写死默认值"导致跨子域单点登录失效

脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定);
测试夹具/示例/错误页/脚本/后台占位提示全部通用化。

README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。
测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
2026-09-12 10:10:52 +08:00

249 lines
10 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{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)
}