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 } return c.User == portalUsername && 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(` 统一门户登录

统一门户

登录后访问受保护的内部服务

{{if .Error}}
{{.Error}}
{{end}}
京ICP备00000000号-1
`)) 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 !secureEqual(r.FormValue("username"), portalUsername) || !secureEqual(r.FormValue("password"), portalPassword) { w.WriteHeader(http.StatusUnauthorized) renderPortalLogin(w, redirect, "用户名或密码错误") return } token, err := signClaims(portalClaims{User: portalUsername, 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) }