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") } }