From 65562f56c0e79fb5832c048c85a3537f6b84cd2e Mon Sep 17 00:00:00 2001 From: qxo <49526356@qq.com> Date: Sun, 22 Mar 2026 13:04:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=B7=BB=E5=8A=A0=20Basic=20Au?= =?UTF-8?q?th=20=E8=AE=A4=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 Basic Auth 中间件保护 /ui/ 和 /api/ 路由 - 默认启用 Basic Auth,首次启动时自动生成随机密码 - 支持通过环境变量配置: CCNEXUS_BASIC_AUTH_ENABLED, CCNEXUS_BASIC_AUTH_USERNAME, CCNEXUS_BASIC_AUTH_PASSWORD - 添加 API 端点: /api/config/basic-auth, /api/config/basic-auth/reset-password --- cmd/server/Dockerfile | 2 + cmd/server/main.go | 42 ++++++++++++++++ cmd/server/webui/api/config.go | 80 +++++++++++++++++++++++++++++- cmd/server/webui/api/handler.go | 78 ++++++++++++++++++++--------- cmd/server/webui/api/middleware.go | 62 +++++++++++++++++++++++ cmd/server/webui/webui.go | 18 ++++--- internal/config/config.go | 72 ++++++++++++++++++++++++--- 7 files changed, 316 insertions(+), 38 deletions(-) diff --git a/cmd/server/Dockerfile b/cmd/server/Dockerfile index 3ab8bd8b..90d9bbb2 100644 --- a/cmd/server/Dockerfile +++ b/cmd/server/Dockerfile @@ -39,6 +39,8 @@ COPY --from=builder /app/ccnexus-server /app/ccnexus-server ENV CCNEXUS_DATA_DIR=/data ENV CCNEXUS_PORT=3000 ENV CCNEXUS_DB_PATH=/data/ccnexus.db +ENV CCNEXUS_BASIC_AUTH_ENABLED=true +ENV CCNEXUS_BASIC_AUTH_USERNAME=admin # Expose HTTP API port EXPOSE 3000 diff --git a/cmd/server/main.go b/cmd/server/main.go index ee850476..34de96f2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,8 @@ package main import ( + "crypto/rand" + "encoding/hex" "errors" "net/http" "os" @@ -40,6 +42,21 @@ func main() { os.Exit(1) } + if cfg.BasicAuthEnabled && cfg.BasicAuthPassword == "" { + randomPassword := generateRandomPassword(16) + cfg.BasicAuthPassword = randomPassword + logger.Info("======================================") + logger.Info(" Basic Auth 密码已随机生成") + logger.Info(" 用户名: %s", cfg.BasicAuthUsername) + logger.Info(" 密码: %s", randomPassword) + logger.Info(" 请妥善保存,密码不会再次显示") + logger.Info("======================================") + adapter := storage.NewConfigStorageAdapter(sqliteStorage) + _ = cfg.SaveToStorage(adapter) + } else if cfg.BasicAuthEnabled { + logger.Info("Basic Auth 已启用,用户名: %s", cfg.BasicAuthUsername) + } + applyEnvOverrides(cfg) setLogLevels(cfg.GetLogLevel()) @@ -142,6 +159,19 @@ func applyEnvOverrides(cfg *config.Config) { logger.Warn("Invalid CCNEXUS_LOG_LEVEL value %q: %v", levelStr, err) } } + + if authEnabled := os.Getenv("CCNEXUS_BASIC_AUTH_ENABLED"); authEnabled != "" { + enabled := authEnabled == "1" || authEnabled == "true" + cfg.BasicAuthEnabled = enabled + } + + if username := os.Getenv("CCNEXUS_BASIC_AUTH_USERNAME"); username != "" { + cfg.BasicAuthUsername = username + } + + if password := os.Getenv("CCNEXUS_BASIC_AUTH_PASSWORD"); password != "" { + cfg.BasicAuthPassword = password + } } func setLogLevels(level int) { @@ -151,3 +181,15 @@ func setLogLevels(level int) { logger.GetLogger().SetMinLevel(logger.LogLevel(level)) logger.GetLogger().SetConsoleLevel(logger.LogLevel(level)) } + +func generateRandomPassword(length int) string { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + fallback := make([]byte, length) + for i := range fallback { + fallback[i] = byte(i*7%26 + 'a') + } + return string(fallback) + } + return hex.EncodeToString(bytes)[:length] +} \ No newline at end of file diff --git a/cmd/server/webui/api/config.go b/cmd/server/webui/api/config.go index d96c83fc..44b84bd5 100644 --- a/cmd/server/webui/api/config.go +++ b/cmd/server/webui/api/config.go @@ -1,6 +1,8 @@ package api import ( + "crypto/rand" + "encoding/hex" "encoding/json" "net/http" @@ -8,6 +10,12 @@ import ( "github.com/lich0821/ccNexus/internal/storage" ) +type BasicAuthConfigRequest struct { + Enabled bool `json:"enabled"` + Username string `json:"username"` + Password string `json:"password"` +} + // handleConfig handles GET and PUT for full configuration func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -20,6 +28,76 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) handleBasicAuthConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + WriteSuccess(w, map[string]interface{}{ + "enabled": h.config.BasicAuthEnabled, + "username": h.config.BasicAuthUsername, + "password": "***", + }) + case http.MethodPut: + var req BasicAuthConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteError(w, http.StatusBadRequest, "Invalid request body") + return + } + + h.config.BasicAuthEnabled = req.Enabled + if req.Username != "" { + h.config.BasicAuthUsername = req.Username + } + if req.Password != "" && req.Password != "***" { + h.config.BasicAuthPassword = req.Password + } + + adapter := storage.NewConfigStorageAdapter(h.storage) + if err := h.config.SaveToStorage(adapter); err != nil { + logger.Error("Failed to save config: %v", err) + WriteError(w, http.StatusInternalServerError, "Failed to save configuration") + return + } + + WriteSuccess(w, map[string]interface{}{ + "message": "Basic Auth configuration updated", + "enabled": h.config.BasicAuthEnabled, + "username": h.config.BasicAuthUsername, + }) + default: + WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") + } +} + +func (h *Handler) handleResetBasicAuthPassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + WriteError(w, http.StatusInternalServerError, "Failed to generate password") + return + } + newPassword := hex.EncodeToString(bytes)[:16] + + h.config.BasicAuthPassword = newPassword + + adapter := storage.NewConfigStorageAdapter(h.storage) + if err := h.config.SaveToStorage(adapter); err != nil { + logger.Error("Failed to save config: %v", err) + WriteError(w, http.StatusInternalServerError, "Failed to save configuration") + return + } + + logger.Info("Basic Auth password has been reset via API") + + WriteSuccess(w, map[string]interface{}{ + "message": "Password reset successfully", + "password": newPassword, + }) +} + // getConfig returns the full configuration func (h *Handler) getConfig(w http.ResponseWriter, r *http.Request) { WriteSuccess(w, map[string]interface{}{ @@ -147,4 +225,4 @@ func (h *Handler) handleConfigLogLevel(w http.ResponseWriter, r *http.Request) { default: WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") } -} +} \ No newline at end of file diff --git a/cmd/server/webui/api/handler.go b/cmd/server/webui/api/handler.go index 4c67f60d..09be81d0 100644 --- a/cmd/server/webui/api/handler.go +++ b/cmd/server/webui/api/handler.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "github.com/lich0821/ccNexus/internal/config" "github.com/lich0821/ccNexus/internal/proxy" @@ -13,6 +14,7 @@ type Handler struct { config *config.Config proxy *proxy.Proxy storage *storage.SQLiteStorage + auth AuthConfig } // NewHandler creates a new API handler @@ -21,31 +23,61 @@ func NewHandler(cfg *config.Config, p *proxy.Proxy, s *storage.SQLiteStorage) *H config: cfg, proxy: p, storage: s, + auth: AuthConfig{ + Enabled: cfg.BasicAuthEnabled, + Username: cfg.BasicAuthUsername, + Password: cfg.BasicAuthPassword, + }, } } -// RegisterRoutes registers all API routes -func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - // Endpoint management - mux.HandleFunc("/api/endpoints", h.handleEndpoints) - mux.HandleFunc("/api/endpoints/", h.handleEndpointByName) - mux.HandleFunc("/api/endpoints/current", h.handleCurrentEndpoint) - mux.HandleFunc("/api/endpoints/switch", h.handleSwitchEndpoint) - mux.HandleFunc("/api/endpoints/reorder", h.handleReorderEndpoints) - mux.HandleFunc("/api/endpoints/fetch-models", h.handleFetchModels) - - // Statistics - mux.HandleFunc("/api/stats/summary", h.handleStatsSummary) - mux.HandleFunc("/api/stats/daily", h.handleStatsDaily) - mux.HandleFunc("/api/stats/weekly", h.handleStatsWeekly) - mux.HandleFunc("/api/stats/monthly", h.handleStatsMonthly) - mux.HandleFunc("/api/stats/trends", h.handleStatsTrends) +// ServeHTTP implements http.Handler interface +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } - // Configuration - mux.HandleFunc("/api/config", h.handleConfig) - mux.HandleFunc("/api/config/port", h.handleConfigPort) - mux.HandleFunc("/api/config/log-level", h.handleConfigLogLevel) + authMiddleware := BasicAuthMiddleware(h.auth) - // Real-time events - mux.HandleFunc("/api/events", h.handleEvents) -} + switch path { + case "/api/endpoints": + authMiddleware(http.HandlerFunc(h.handleEndpoints)).ServeHTTP(w, r) + case "/api/endpoints/current": + authMiddleware(http.HandlerFunc(h.handleCurrentEndpoint)).ServeHTTP(w, r) + case "/api/endpoints/switch": + authMiddleware(http.HandlerFunc(h.handleSwitchEndpoint)).ServeHTTP(w, r) + case "/api/endpoints/reorder": + authMiddleware(http.HandlerFunc(h.handleReorderEndpoints)).ServeHTTP(w, r) + case "/api/endpoints/fetch-models": + authMiddleware(http.HandlerFunc(h.handleFetchModels)).ServeHTTP(w, r) + case "/api/stats/summary": + authMiddleware(http.HandlerFunc(h.handleStatsSummary)).ServeHTTP(w, r) + case "/api/stats/daily": + authMiddleware(http.HandlerFunc(h.handleStatsDaily)).ServeHTTP(w, r) + case "/api/stats/weekly": + authMiddleware(http.HandlerFunc(h.handleStatsWeekly)).ServeHTTP(w, r) + case "/api/stats/monthly": + authMiddleware(http.HandlerFunc(h.handleStatsMonthly)).ServeHTTP(w, r) + case "/api/stats/trends": + authMiddleware(http.HandlerFunc(h.handleStatsTrends)).ServeHTTP(w, r) + case "/api/config": + authMiddleware(http.HandlerFunc(h.handleConfig)).ServeHTTP(w, r) + case "/api/config/port": + authMiddleware(http.HandlerFunc(h.handleConfigPort)).ServeHTTP(w, r) + case "/api/config/log-level": + authMiddleware(http.HandlerFunc(h.handleConfigLogLevel)).ServeHTTP(w, r) + case "/api/config/basic-auth": + authMiddleware(http.HandlerFunc(h.handleBasicAuthConfig)).ServeHTTP(w, r) + case "/api/config/basic-auth/reset-password": + authMiddleware(http.HandlerFunc(h.handleResetBasicAuthPassword)).ServeHTTP(w, r) + case "/api/events": + authMiddleware(http.HandlerFunc(h.handleEvents)).ServeHTTP(w, r) + default: + if strings.HasPrefix(path, "/api/endpoints/") { + authMiddleware(http.HandlerFunc(h.handleEndpointByName)).ServeHTTP(w, r) + return + } + http.NotFound(w, r) + } +} \ No newline at end of file diff --git a/cmd/server/webui/api/middleware.go b/cmd/server/webui/api/middleware.go index aa6e38e7..3d04a35e 100644 --- a/cmd/server/webui/api/middleware.go +++ b/cmd/server/webui/api/middleware.go @@ -1,8 +1,11 @@ package api import ( + "crypto/subtle" + "encoding/base64" "encoding/json" "net/http" + "strings" "github.com/lich0821/ccNexus/internal/logger" ) @@ -77,3 +80,62 @@ func LoggingMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +type AuthConfig struct { + Enabled bool + Username string + Password string +} + +func BasicAuthMiddleware(auth AuthConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !auth.Enabled { + next.ServeHTTP(w, r) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + const prefix = "Basic " + if !strings.HasPrefix(authHeader, prefix) { + w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + encoded := strings.TrimPrefix(authHeader, prefix) + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + credentials := string(decoded) + colonIndex := strings.Index(credentials, ":") + if colonIndex < 0 { + w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + username := credentials[:colonIndex] + password := credentials[colonIndex+1:] + + if subtle.ConstantTimeCompare([]byte(auth.Username), []byte(username)) != 1 || + subtle.ConstantTimeCompare([]byte(auth.Password), []byte(password)) != 1 { + w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} \ No newline at end of file diff --git a/cmd/server/webui/webui.go b/cmd/server/webui/webui.go index 86986b6d..bf326ddf 100644 --- a/cmd/server/webui/webui.go +++ b/cmd/server/webui/webui.go @@ -16,34 +16,40 @@ var uiFS embed.FS // WebUI represents the web management interface type WebUI struct { + cfg *config.Config apiHandler *api.Handler } // New creates a new WebUI instance func New(cfg *config.Config, p *proxy.Proxy, storage *storage.SQLiteStorage) *WebUI { return &WebUI{ + cfg: cfg, apiHandler: api.NewHandler(cfg, p, storage), } } // RegisterRoutes registers all web UI routes to the provided mux func (w *WebUI) RegisterRoutes(mux *http.ServeMux) error { - // Register API routes - w.apiHandler.RegisterRoutes(mux) + mux.HandleFunc("/api/", w.apiHandler.ServeHTTP) + + authConfig := api.AuthConfig{ + Enabled: w.cfg.BasicAuthEnabled, + Username: w.cfg.BasicAuthUsername, + Password: w.cfg.BasicAuthPassword, + } + authMiddleware := api.BasicAuthMiddleware(authConfig) - // Serve embedded UI files uiSubFS, err := fs.Sub(uiFS, "ui") if err != nil { return err } - uiHandler := http.FileServer(http.FS(uiSubFS)) + uiHandler := authMiddleware(http.FileServer(http.FS(uiSubFS))) mux.Handle("/ui/", http.StripPrefix("/ui/", uiHandler)) - // Redirect /admin to /ui/ mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/", http.StatusFound) }) return nil -} +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index eb01be81..cd5b3d73 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -159,8 +159,11 @@ type ProxyConfig struct { // Config represents the application configuration type Config struct { - Port int `json:"port"` - Endpoints []Endpoint `json:"endpoints"` + Port int `json:"port"` + BasicAuthEnabled bool `json:"basicAuthEnabled"` + BasicAuthUsername string `json:"basicAuthUsername"` + BasicAuthPassword string `json:"basicAuthPassword"` + Endpoints []Endpoint `json:"endpoints"` LogLevel int `json:"logLevel"` // 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR Language string `json:"language"` // UI language: en, zh-CN Theme string `json:"theme"` // UI theme: light, dark @@ -184,8 +187,11 @@ type Config struct { // DefaultConfig returns a default configuration func DefaultConfig() *Config { return &Config{ - Port: 3000, - LogLevel: 1, // Default to INFO level + Port: 3000, + BasicAuthEnabled: true, + BasicAuthUsername: "admin", + BasicAuthPassword: "", + LogLevel: 1, // Default to INFO level Language: "zh-CN", // Default to Chinese WindowWidth: 1024, // Default window width WindowHeight: 768, // Default window height @@ -270,6 +276,36 @@ func (c *Config) GetLogLevel() int { return c.LogLevel } +// GetBasicAuthEnabled returns whether Basic Auth is enabled (thread-safe) +func (c *Config) GetBasicAuthEnabled() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.BasicAuthEnabled +} + +// GetBasicAuthUsername returns Basic Auth username (thread-safe) +func (c *Config) GetBasicAuthUsername() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.BasicAuthUsername +} + +// GetBasicAuthPassword returns Basic Auth password (thread-safe) +func (c *Config) GetBasicAuthPassword() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.BasicAuthPassword +} + +// UpdateBasicAuth updates Basic Auth configuration (thread-safe) +func (c *Config) UpdateBasicAuth(enabled bool, username, password string) { + c.mu.Lock() + defer c.mu.Unlock() + c.BasicAuthEnabled = enabled + c.BasicAuthUsername = username + c.BasicAuthPassword = password +} + // UpdateEndpoints updates the endpoints (thread-safe) func (c *Config) UpdateEndpoints(endpoints []Endpoint) { c.mu.Lock() @@ -531,9 +567,8 @@ type StorageEndpoint struct { // LoadFromStorage loads configuration from SQLite storage func LoadFromStorage(storage StorageAdapter) (*Config, error) { - config := &Config{ - Endpoints: []Endpoint{}, - } + config := DefaultConfig() + config.Endpoints = []Endpoint{} // Load endpoints endpoints, err := storage.GetEndpoints() @@ -746,6 +781,17 @@ func LoadFromStorage(storage StorageAdapter) (*Config, error) { config.ClaudeNotificationType = "toast" } + // Load Basic Auth config + if enabledStr, err := storage.GetConfig("basicAuthEnabled"); err == nil && enabledStr != "" { + config.BasicAuthEnabled = enabledStr == "true" + } + if username, err := storage.GetConfig("basicAuthUsername"); err == nil && username != "" { + config.BasicAuthUsername = username + } + if password, err := storage.GetConfig("basicAuthPassword"); err == nil && password != "" { + config.BasicAuthPassword = password + } + return config, nil } @@ -961,5 +1007,15 @@ func (c *Config) SaveToStorage(storage StorageAdapter) error { return fmt.Errorf("failed to save claude_notification_type config: %w", err) } + if err := storage.SetConfig("basicAuthEnabled", strconv.FormatBool(c.BasicAuthEnabled)); err != nil { + return fmt.Errorf("failed to save basicAuthEnabled config: %w", err) + } + if err := storage.SetConfig("basicAuthUsername", c.BasicAuthUsername); err != nil { + return fmt.Errorf("failed to save basicAuthUsername config: %w", err) + } + if err := storage.SetConfig("basicAuthPassword", c.BasicAuthPassword); err != nil { + return fmt.Errorf("failed to save basicAuthPassword config: %w", err) + } + return nil -} +} \ No newline at end of file