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 类型生成。
This commit is contained in:
@ -92,7 +92,9 @@ func verifyClaims(token string) bool {
|
||||
if json.Unmarshal(payload, &c) != nil {
|
||||
return false
|
||||
}
|
||||
return c.User == portalUsername && c.Expiry >= time.Now().Unix()
|
||||
// 用户名可能已被后台改过,按当前配置比较,避免改完账号旧会话仍有效
|
||||
user, _ := portalAuthConfigured()
|
||||
return c.User == user && c.Expiry >= time.Now().Unix()
|
||||
}
|
||||
|
||||
func portalAuthenticated(r *http.Request) bool {
|
||||
@ -167,12 +169,13 @@ func handlePortalLogin(w http.ResponseWriter, r *http.Request) {
|
||||
renderPortalLogin(w, redirect, "请求解析失败")
|
||||
return
|
||||
}
|
||||
if !secureEqual(r.FormValue("username"), portalUsername) || !secureEqual(r.FormValue("password"), portalPassword) {
|
||||
if !checkPortalCredentials(r.FormValue("username"), r.FormValue("password")) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
renderPortalLogin(w, redirect, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := signClaims(portalClaims{User: portalUsername, Expiry: time.Now().Add(portalSessionTTL).Unix()})
|
||||
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
|
||||
|
||||
@ -124,7 +124,7 @@ func ValidateRoute(r Route) error {
|
||||
if kind != "proxy" && kind != "portal" && kind != "auth" {
|
||||
return fmt.Errorf("%s: 未知服务类型 %q", host, r.Kind)
|
||||
}
|
||||
if kind != "portal" {
|
||||
if kind == "proxy" {
|
||||
up := strings.TrimSpace(r.Upstream)
|
||||
if up == "" {
|
||||
return fmt.Errorf("%s: 后端地址不能为空", host)
|
||||
@ -251,6 +251,11 @@ func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error {
|
||||
b.WriteString(" index index.html;\n location / { try_files $uri $uri/ /404.html; }\n}\n\n")
|
||||
return nil
|
||||
}
|
||||
if routeKind(r) == "auth" {
|
||||
// 登录域名由容器内自带的登录服务直接响应,替代原先的 portal-auth 容器
|
||||
r.Upstream = "http://127.0.0.1:3005"
|
||||
r.RequireLogin = false
|
||||
}
|
||||
if r.RequireLogin {
|
||||
if strings.TrimSpace(cfg.AuthURL) == "" || strings.TrimSpace(cfg.LoginURL) == "" {
|
||||
return fmt.Errorf("%s: 需要认证但未配置认证/登录地址", host)
|
||||
|
||||
182
admin-server/gateway_import.go
Normal file
182
admin-server/gateway_import.go
Normal file
@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── 从现有 nginx 配置导入路由 ──
|
||||
//
|
||||
// 迁移期需要把老的统一入口(portal-nginx)里已有的域名→后端映射一次性读进来,
|
||||
// 否则后台"统一入口"永远是空的(本轮实际发生过)。这里不去猜文件位置,
|
||||
// 而是让使用者把老的 nginx 配置贴在界面上,服务端只做解析:
|
||||
// 解析结果先回给界面供确认,用户点保存/应用才真正落盘——不做任何隐式写入。
|
||||
|
||||
var (
|
||||
reMapEntry = regexp.MustCompile(`(?m)^\s*([A-Za-z0-9*._-]+)\s+(https?://[^\s;]+)\s*;`)
|
||||
reServerName = regexp.MustCompile(`(?m)^\s*server_name\s+([^;]+);`)
|
||||
reProxyPass = regexp.MustCompile(`proxy_pass\s+(\$backend|\$[A-Za-z_]+|https?://[^\s;]+)\s*;`)
|
||||
)
|
||||
|
||||
// parseNginxConf 把老配置解析成路由初稿。
|
||||
//
|
||||
// 返回的 Route 一律 NavEnabled=false:导航项归 Dashy 管,导入只负责"域名→后端",
|
||||
// 勾选「加入导航」才会接管对应导航项,避免一导入就把手工维护的导航改掉。
|
||||
func parseNginxConf(text string) ([]Route, error) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, fmt.Errorf("配置内容为空")
|
||||
}
|
||||
upstreams := map[string]string{}
|
||||
for _, m := range reMapEntry.FindAllStringSubmatch(text, -1) {
|
||||
host := strings.ToLower(strings.TrimSpace(m[1]))
|
||||
if host == "default" || host == "hostnames" {
|
||||
continue
|
||||
}
|
||||
upstreams[host] = m[2]
|
||||
}
|
||||
|
||||
// 逐块扫描 server { },用大括号计数找边界(配置里没有嵌套 server)
|
||||
var routes []Route
|
||||
idx := 0
|
||||
for {
|
||||
start := strings.Index(text[idx:], "server")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
start += idx
|
||||
brace := strings.Index(text[start:], "{")
|
||||
if brace < 0 {
|
||||
break
|
||||
}
|
||||
open := start + brace
|
||||
depth, end := 0, -1
|
||||
for i := open; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = i
|
||||
}
|
||||
}
|
||||
if end >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
body := text[open+1 : end]
|
||||
idx = end + 1
|
||||
|
||||
sn := reServerName.FindStringSubmatch(body)
|
||||
if sn == nil {
|
||||
continue
|
||||
}
|
||||
hosts := strings.Fields(strings.TrimSpace(sn[1]))
|
||||
if len(hosts) == 0 || (len(hosts) == 1 && hosts[0] == "_") {
|
||||
continue // 默认/兜底段不是具体服务
|
||||
}
|
||||
|
||||
hasAuth := strings.Contains(body, "auth_request /_auth;")
|
||||
hasSSE := strings.Contains(body, "postpone_output 0")
|
||||
hasBeian := strings.Contains(body, "sub_filter")
|
||||
hasWS := strings.Contains(body, "Upgrade $http_upgrade")
|
||||
insecure := strings.Contains(body, "proxy_ssl_verify off")
|
||||
proxyLit := ""
|
||||
if pm := reProxyPass.FindStringSubmatch(body); pm != nil {
|
||||
proxyLit = pm[1]
|
||||
}
|
||||
bypass := []string{}
|
||||
if strings.Contains(body, "location /search") {
|
||||
bypass = append(bypass, "/search")
|
||||
}
|
||||
if strings.Contains(body, "autocompleter") {
|
||||
bypass = append(bypass, "/api/", "/autocompleter/", "/static/")
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
h := strings.ToLower(strings.TrimSpace(host))
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
up := upstreams[h]
|
||||
if up == "" && strings.HasPrefix(proxyLit, "http") {
|
||||
up = proxyLit
|
||||
}
|
||||
r := Route{
|
||||
Host: h,
|
||||
Upstream: up,
|
||||
RequireLogin: hasAuth,
|
||||
Websocket: hasWS,
|
||||
Streaming: hasSSE,
|
||||
InsecureTLS: insecure,
|
||||
InjectBeian: hasBeian,
|
||||
BypassPaths: bypass,
|
||||
}
|
||||
// 登录服务与纯静态门户段没有内网后端:分别归为 auth / portal 类型
|
||||
if strings.HasPrefix(h, "login.") || strings.Contains(up, ":4001") {
|
||||
r.Kind, r.Upstream = "auth", ""
|
||||
r.RequireLogin = false
|
||||
} else if up == "" {
|
||||
r.Kind = "portal"
|
||||
r.RequireLogin = false
|
||||
}
|
||||
r.Note = "导入"
|
||||
routes = append(routes, r)
|
||||
}
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
return nil, fmt.Errorf("没有解析到任何 server_name(请确认贴的是完整 nginx 配置)")
|
||||
}
|
||||
|
||||
// 去重:同一域名只保留第一条
|
||||
seen := map[string]bool{}
|
||||
out := make([]Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
if seen[r.Host] {
|
||||
continue
|
||||
}
|
||||
seen[r.Host] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
// 校验一遍,早发现问题(缺后端等)比等用户点应用时才发现好
|
||||
for _, r := range out {
|
||||
if err := ValidateRoute(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// handleGatewayImport 解析贴进来的 nginx 配置,返回路由初稿(不写盘)。
|
||||
func handleGatewayImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
if !requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Conf string `json:"conf"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "请求体解析失败")
|
||||
return
|
||||
}
|
||||
routes, err := parseNginxConf(req.Conf)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResp(w, map[string]any{
|
||||
"routes": routes, "count": len(routes),
|
||||
"hint": "已解析 " + strconv.Itoa(len(routes)) + " 个域名(未写入)。确认后点「保存并应用」;勾选「加入导航」的条目才会同时接管 Dashy 导航项。",
|
||||
})
|
||||
}
|
||||
147
admin-server/gateway_import_test.go
Normal file
147
admin-server/gateway_import_test.go
Normal file
@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleConf = `map $host $backend {
|
||||
hostnames;
|
||||
default "";
|
||||
gitea.example.com http://127.0.0.1:3000;
|
||||
login.example.com http://127.0.0.1:4001;
|
||||
dashy.example.com http://127.0.0.1:3003;
|
||||
modulerouter.example.com http://10.0.0.11:8081;
|
||||
}
|
||||
server {
|
||||
listen 3080 ssl;
|
||||
server_name example.com www.example.com;
|
||||
root /usr/share/nginx/html;
|
||||
error_page 404 /404.html;
|
||||
}
|
||||
server {
|
||||
listen 3080 ssl;
|
||||
server_name gitea.example.com;
|
||||
location / {
|
||||
sub_filter '</footer>' 'x';
|
||||
proxy_pass $backend; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";
|
||||
proxy_ssl_verify off;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 3080 ssl;
|
||||
server_name login.example.com;
|
||||
location / { proxy_pass $backend; }
|
||||
}
|
||||
server {
|
||||
listen 3080 ssl;
|
||||
server_name modulerouter.example.com dify.example.com;
|
||||
location / {
|
||||
postpone_output 0; proxy_buffering off;
|
||||
proxy_pass $backend;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 3080 ssl;
|
||||
server_name dashy.example.com;
|
||||
auth_request /_auth;
|
||||
error_page 401 = @login;
|
||||
location @login { return 302 https://login.example.com/login; }
|
||||
location /search { auth_request off; proxy_pass $backend; }
|
||||
location ~ ^/(api|autocompleter|static)/ { auth_request off; proxy_pass $backend; }
|
||||
location / { proxy_pass $backend; }
|
||||
}
|
||||
server {
|
||||
listen 3080 ssl default_server;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
location / { return 404; }
|
||||
}
|
||||
`
|
||||
|
||||
func TestParseNginxConf(t *testing.T) {
|
||||
routes, err := parseNginxConf(sampleConf)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
byHost := map[string]Route{}
|
||||
for _, r := range routes {
|
||||
byHost[r.Host] = r
|
||||
}
|
||||
if _, ok := byHost["_"]; ok {
|
||||
t.Fatalf("不应把默认段当成服务")
|
||||
}
|
||||
d, ok := byHost["dashy.example.com"]
|
||||
if !ok || !d.RequireLogin || d.Upstream != "http://127.0.0.1:3003" {
|
||||
t.Fatalf("dashy 解析不对: %+v", d)
|
||||
}
|
||||
if len(d.BypassPaths) == 0 {
|
||||
t.Fatalf("dashy 应解析出免认证路径: %+v", d.BypassPaths)
|
||||
}
|
||||
g := byHost["gitea.example.com"]
|
||||
if g.RequireLogin || !g.InjectBeian || !g.InsecureTLS || !g.Websocket {
|
||||
t.Fatalf("gitea 解析不对: %+v", g)
|
||||
}
|
||||
m := byHost["modulerouter.example.com"]
|
||||
if m.RequireLogin || !m.Streaming {
|
||||
t.Fatalf("modulerouter 应解析为免认证 + 流式: %+v", m)
|
||||
}
|
||||
if _, ok := byHost["dify.example.com"]; !ok {
|
||||
t.Fatalf("同一段里多个域名都要生成路由")
|
||||
}
|
||||
if l := byHost["login.example.com"]; l.Kind != "auth" || l.Upstream != "" {
|
||||
t.Fatalf("login 应为内置 auth: %+v", l)
|
||||
}
|
||||
if p := byHost["example.com"]; p.Kind != "portal" {
|
||||
t.Fatalf("静态门户段应为 portal: %+v", p)
|
||||
}
|
||||
for _, r := range routes {
|
||||
if r.NavEnabled {
|
||||
t.Fatalf("导入不应默认接管导航: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNginxConfRejectsGarbage(t *testing.T) {
|
||||
if _, err := parseNginxConf(" "); err == nil {
|
||||
t.Fatalf("空内容应报错")
|
||||
}
|
||||
if _, err := parseNginxConf("hello world"); err == nil {
|
||||
t.Fatalf("没有任何 server_name 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalAuthStore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldConf := confFile
|
||||
confFile = dir + "/conf.yml"
|
||||
defer func() { confFile = oldConf }()
|
||||
|
||||
if err := savePortalAuthFile("jianf", "s3cret-pass"); err != nil {
|
||||
t.Fatalf("保存失败: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(portalAuthPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "s3cret-pass") {
|
||||
t.Fatalf("不得写入明文口令: %s", raw)
|
||||
}
|
||||
if !checkPortalCredentials("jianf", "s3cret-pass") {
|
||||
t.Fatalf("正确口令应通过")
|
||||
}
|
||||
if checkPortalCredentials("jianf", "wrong") || checkPortalCredentials("other", "s3cret-pass") {
|
||||
t.Fatalf("错误口令/用户名不得通过")
|
||||
}
|
||||
if err := savePortalAuthFile("admin2", "another-pass"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checkPortalCredentials("jianf", "s3cret-pass") || !checkPortalCredentials("admin2", "another-pass") {
|
||||
t.Fatalf("改账号后校验未按新值")
|
||||
}
|
||||
user, source := portalAuthConfigured()
|
||||
if user != "admin2" || !strings.Contains(source, "数据卷") {
|
||||
t.Fatalf("配置来源不对: %s %s", user, source)
|
||||
}
|
||||
}
|
||||
@ -298,3 +298,19 @@ func TestSyncGatewayNavigationMissingSection(t *testing.T) {
|
||||
t.Fatalf("分组不存在时应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 登录域名必须指向容器内自带的登录服务,不能生成空的 proxy_pass。
|
||||
func TestRenderGatewayConfAuthKind(t *testing.T) {
|
||||
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{
|
||||
{Host: "login.example.com", Kind: "auth"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("auth 类型不应要求后端: %v", err)
|
||||
}
|
||||
if !contains(out, "proxy_pass http://127.0.0.1:3005;") {
|
||||
t.Fatalf("auth 类型应指向内置登录服务:\n%s", out)
|
||||
}
|
||||
if contains(out, "proxy_pass ;") {
|
||||
t.Fatalf("生成了空的 proxy_pass:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1184,6 +1184,10 @@ func main() {
|
||||
http.HandleFunc("/api/frameable", cors(handleFrameable))
|
||||
http.HandleFunc("/api/gateway/routes", cors(handleGatewayRoutes))
|
||||
http.HandleFunc("/api/gateway/apply", cors(handleGatewayApply))
|
||||
// 从现有 nginx 配置导入路由(只解析,不写盘)
|
||||
http.HandleFunc("/api/gateway/import", cors(handleGatewayImport))
|
||||
// 统一登录的账号设置(存数据卷,改完立即生效,不用重新部署)
|
||||
http.HandleFunc("/api/portal-auth", cors(handlePortalAuthSettings))
|
||||
// 历史记录管理:列表 / 清空全部 / 删除单条
|
||||
http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
|
||||
133
admin-server/portal_auth.go
Normal file
133
admin-server/portal_auth.go
Normal file
@ -0,0 +1,133 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user