Files
HomeDash/admin-server/auth.go
HomeDash Bot 53beff646a feat(admin): 现有配置导入 + 添加表单补齐认证/导航开关 + 统一登录账号设置 + 设置分组
针对反馈的五个问题:

1. 后台"没有映射":新增 /admin/api/gateway/import 与界面「从现有 nginx 配置导入」,
   把老统一入口的 nginx 配置贴进来即可解析出全部「域名 → 后端」初稿
   (含 requireLogin / 免认证路径 / SSE 流式 / 备案注入 / 自签证书 / auth 与 portal 类型)。
   解析只读不落盘,确认后才保存;导入默认不接管导航,避免一导入就改掉手工导航。
2. 添加转发时没有导航选项:添加表单补齐「同时加入 Dashy 导航 / 分组 / 名称 / 描述 /
   图标 / 自动抓取」。
3. 添加转发时没有 portal 保护选项:添加表单补齐「启用 portal 统一登录保护」,
   并补上 WebSocket 透传与上传上限。
4. 设置里无法配置 portal 账号:新增统一登录账号卡片,账号存数据卷
   (portal-auth.json,只存加盐哈希),优先级 数据卷 > 环境变量,
   改完立即生效、无需重新部署;会话校验按当前用户名,改账号后旧会话失效。
5. 设置项太乱:按用途分组(站点与外观 / 导航内容 / 历史记录 / 转发与统一登录 / 高级),
   外观卡片并入站点组。

另修两个本轮引入的缺陷:
- 容器假 unhealthy:内部端口改 18080 后,基础镜像的 HEALTHCHECK 仍指向 8080;
  部署脚本生成的 Dockerfile 现在显式覆盖为 :18080/healthz。
- kind=auth 不依赖 portal-auth 容器,指向内置登录服务;避免生成空 proxy_pass。

测试:nginx 配置解析(含兜底段忽略、多域名段、类型判定、默认不接管导航)、
垃圾输入报错、账号存储(不明文、可校验、改名后旧口令失效)、auth 类型生成。
2026-09-11 22:00:09 +08:00

220 lines
8.2 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")
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><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>`))
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")
_ = portalLoginTemplate.Execute(w, map[string]string{
"Redirect": url.QueryEscape(redirect), "Error": errText,
})
}
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)
}