Files
HomeDash/admin-server/portal_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

134 lines
3.7 KiB
Go
Raw Permalink 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/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
)
// ── 统一登录的账号设置 ──
//
// 原先账号只能靠容器环境变量(PORTAL_USERNAME/PORTAL_PASSWORD)配置,
// 改一次就要重新部署。这里把它落到**数据卷**里,后台可以直接改:
// 优先级 = 数据卷里的设置 > 环境变量 > 默认值。
//
// 只存口令的加盐哈希,不存明文;加盐值随文件一起保存。
type portalAuthFile struct {
Username string `json:"username"`
Salt string `json:"salt"`
Hash string `json:"hash"`
}
func portalAuthPath() string {
return filepath.Join(filepath.Dir(confFile), "portal-auth.json")
}
func hashPassword(salt, password string) string {
sum := sha256.Sum256([]byte(salt + "\x00" + password))
return hex.EncodeToString(sum[:])
}
func loadPortalAuthFile() (*portalAuthFile, error) {
b, err := os.ReadFile(portalAuthPath())
if err != nil {
return nil, err
}
var f portalAuthFile
if err := json.Unmarshal(b, &f); err != nil {
return nil, err
}
return &f, nil
}
func savePortalAuthFile(user, password string) error {
old, _ := loadPortalAuthFile()
salt := ""
if old != nil && old.Salt != "" {
salt = old.Salt
} else {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return err
}
salt = hex.EncodeToString(buf)
}
f := portalAuthFile{Username: strings.TrimSpace(user), Salt: salt, Hash: hashPassword(salt, password)}
data, err := json.MarshalIndent(f, "", " ")
if err != nil {
return err
}
path := portalAuthPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// checkPortalCredentials 先看数据卷设置,再回落到环境变量/默认值。
// 返回值表示是否通过。
func checkPortalCredentials(user, password string) bool {
if f, err := loadPortalAuthFile(); err == nil && f.Username != "" && f.Hash != "" {
if !secureEqual(strings.TrimSpace(user), f.Username) {
return false
}
want := f.Hash
got := hashPassword(f.Salt, password)
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
return secureEqual(user, portalUsername) && secureEqual(password, portalPassword)
}
// portalAuthConfigured 报告口令来自数据卷还是环境变量,供界面提示。
func portalAuthConfigured() (user, source string) {
if f, err := loadPortalAuthFile(); err == nil && f.Username != "" {
return f.Username, "数据卷(可在本页修改)"
}
return portalUsername, "环境变量 PORTAL_USERNAME"
}
// handlePortalAuthSettings:GET 返回当前用户名来源;POST 修改账号口令。
func handlePortalAuthSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
user, source := portalAuthConfigured()
jsonResp(w, map[string]any{"username": user, "source": source})
case http.MethodPost:
if !requireAdminToken(w, r) {
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
if strings.TrimSpace(req.Username) == "" {
jsonErr(w, 400, "用户名不能为空")
return
}
if len(req.Password) < 6 {
jsonErr(w, 400, "密码至少 6 位")
return
}
if err := savePortalAuthFile(req.Username, req.Password); err != nil {
jsonErr(w, 500, "保存失败:"+err.Error())
return
}
jsonResp(w, map[string]string{"status": "saved", "username": strings.TrimSpace(req.Username)})
default:
jsonErr(w, 405, "method not allowed")
}
}