Skip to content

Commit e5e5540

Browse files
authored
feat(server): 添加 Basic Auth 认证功能 (#114)
- 实现 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
1 parent 936cfc7 commit e5e5540

7 files changed

Lines changed: 316 additions & 38 deletions

File tree

cmd/server/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ COPY --from=builder /app/ccnexus-server /app/ccnexus-server
3939
ENV CCNEXUS_DATA_DIR=/data
4040
ENV CCNEXUS_PORT=3000
4141
ENV CCNEXUS_DB_PATH=/data/ccnexus.db
42+
ENV CCNEXUS_BASIC_AUTH_ENABLED=true
43+
ENV CCNEXUS_BASIC_AUTH_USERNAME=admin
4244

4345
# Expose HTTP API port
4446
EXPOSE 3000

cmd/server/main.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package main
22

33
import (
4+
"crypto/rand"
5+
"encoding/hex"
46
"errors"
57
"net/http"
68
"os"
@@ -40,6 +42,21 @@ func main() {
4042
os.Exit(1)
4143
}
4244

45+
if cfg.BasicAuthEnabled && cfg.BasicAuthPassword == "" {
46+
randomPassword := generateRandomPassword(16)
47+
cfg.BasicAuthPassword = randomPassword
48+
logger.Info("======================================")
49+
logger.Info(" Basic Auth 密码已随机生成")
50+
logger.Info(" 用户名: %s", cfg.BasicAuthUsername)
51+
logger.Info(" 密码: %s", randomPassword)
52+
logger.Info(" 请妥善保存,密码不会再次显示")
53+
logger.Info("======================================")
54+
adapter := storage.NewConfigStorageAdapter(sqliteStorage)
55+
_ = cfg.SaveToStorage(adapter)
56+
} else if cfg.BasicAuthEnabled {
57+
logger.Info("Basic Auth 已启用,用户名: %s", cfg.BasicAuthUsername)
58+
}
59+
4360
applyEnvOverrides(cfg)
4461
setLogLevels(cfg.GetLogLevel())
4562

@@ -142,6 +159,19 @@ func applyEnvOverrides(cfg *config.Config) {
142159
logger.Warn("Invalid CCNEXUS_LOG_LEVEL value %q: %v", levelStr, err)
143160
}
144161
}
162+
163+
if authEnabled := os.Getenv("CCNEXUS_BASIC_AUTH_ENABLED"); authEnabled != "" {
164+
enabled := authEnabled == "1" || authEnabled == "true"
165+
cfg.BasicAuthEnabled = enabled
166+
}
167+
168+
if username := os.Getenv("CCNEXUS_BASIC_AUTH_USERNAME"); username != "" {
169+
cfg.BasicAuthUsername = username
170+
}
171+
172+
if password := os.Getenv("CCNEXUS_BASIC_AUTH_PASSWORD"); password != "" {
173+
cfg.BasicAuthPassword = password
174+
}
145175
}
146176

147177
func setLogLevels(level int) {
@@ -151,3 +181,15 @@ func setLogLevels(level int) {
151181
logger.GetLogger().SetMinLevel(logger.LogLevel(level))
152182
logger.GetLogger().SetConsoleLevel(logger.LogLevel(level))
153183
}
184+
185+
func generateRandomPassword(length int) string {
186+
bytes := make([]byte, length)
187+
if _, err := rand.Read(bytes); err != nil {
188+
fallback := make([]byte, length)
189+
for i := range fallback {
190+
fallback[i] = byte(i*7%26 + 'a')
191+
}
192+
return string(fallback)
193+
}
194+
return hex.EncodeToString(bytes)[:length]
195+
}

cmd/server/webui/api/config.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
package api
22

33
import (
4+
"crypto/rand"
5+
"encoding/hex"
46
"encoding/json"
57
"net/http"
68

79
"github.com/lich0821/ccNexus/internal/logger"
810
"github.com/lich0821/ccNexus/internal/storage"
911
)
1012

13+
type BasicAuthConfigRequest struct {
14+
Enabled bool `json:"enabled"`
15+
Username string `json:"username"`
16+
Password string `json:"password"`
17+
}
18+
1119
// handleConfig handles GET and PUT for full configuration
1220
func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
1321
switch r.Method {
@@ -20,6 +28,76 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
2028
}
2129
}
2230

31+
func (h *Handler) handleBasicAuthConfig(w http.ResponseWriter, r *http.Request) {
32+
switch r.Method {
33+
case http.MethodGet:
34+
WriteSuccess(w, map[string]interface{}{
35+
"enabled": h.config.BasicAuthEnabled,
36+
"username": h.config.BasicAuthUsername,
37+
"password": "***",
38+
})
39+
case http.MethodPut:
40+
var req BasicAuthConfigRequest
41+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
42+
WriteError(w, http.StatusBadRequest, "Invalid request body")
43+
return
44+
}
45+
46+
h.config.BasicAuthEnabled = req.Enabled
47+
if req.Username != "" {
48+
h.config.BasicAuthUsername = req.Username
49+
}
50+
if req.Password != "" && req.Password != "***" {
51+
h.config.BasicAuthPassword = req.Password
52+
}
53+
54+
adapter := storage.NewConfigStorageAdapter(h.storage)
55+
if err := h.config.SaveToStorage(adapter); err != nil {
56+
logger.Error("Failed to save config: %v", err)
57+
WriteError(w, http.StatusInternalServerError, "Failed to save configuration")
58+
return
59+
}
60+
61+
WriteSuccess(w, map[string]interface{}{
62+
"message": "Basic Auth configuration updated",
63+
"enabled": h.config.BasicAuthEnabled,
64+
"username": h.config.BasicAuthUsername,
65+
})
66+
default:
67+
WriteError(w, http.StatusMethodNotAllowed, "Method not allowed")
68+
}
69+
}
70+
71+
func (h *Handler) handleResetBasicAuthPassword(w http.ResponseWriter, r *http.Request) {
72+
if r.Method != http.MethodPost {
73+
WriteError(w, http.StatusMethodNotAllowed, "Method not allowed")
74+
return
75+
}
76+
77+
bytes := make([]byte, 16)
78+
if _, err := rand.Read(bytes); err != nil {
79+
WriteError(w, http.StatusInternalServerError, "Failed to generate password")
80+
return
81+
}
82+
newPassword := hex.EncodeToString(bytes)[:16]
83+
84+
h.config.BasicAuthPassword = newPassword
85+
86+
adapter := storage.NewConfigStorageAdapter(h.storage)
87+
if err := h.config.SaveToStorage(adapter); err != nil {
88+
logger.Error("Failed to save config: %v", err)
89+
WriteError(w, http.StatusInternalServerError, "Failed to save configuration")
90+
return
91+
}
92+
93+
logger.Info("Basic Auth password has been reset via API")
94+
95+
WriteSuccess(w, map[string]interface{}{
96+
"message": "Password reset successfully",
97+
"password": newPassword,
98+
})
99+
}
100+
23101
// getConfig returns the full configuration
24102
func (h *Handler) getConfig(w http.ResponseWriter, r *http.Request) {
25103
WriteSuccess(w, map[string]interface{}{
@@ -147,4 +225,4 @@ func (h *Handler) handleConfigLogLevel(w http.ResponseWriter, r *http.Request) {
147225
default:
148226
WriteError(w, http.StatusMethodNotAllowed, "Method not allowed")
149227
}
150-
}
228+
}

cmd/server/webui/api/handler.go

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package api
22

33
import (
44
"net/http"
5+
"strings"
56

67
"github.com/lich0821/ccNexus/internal/config"
78
"github.com/lich0821/ccNexus/internal/proxy"
@@ -13,6 +14,7 @@ type Handler struct {
1314
config *config.Config
1415
proxy *proxy.Proxy
1516
storage *storage.SQLiteStorage
17+
auth AuthConfig
1618
}
1719

1820
// NewHandler creates a new API handler
@@ -21,31 +23,61 @@ func NewHandler(cfg *config.Config, p *proxy.Proxy, s *storage.SQLiteStorage) *H
2123
config: cfg,
2224
proxy: p,
2325
storage: s,
26+
auth: AuthConfig{
27+
Enabled: cfg.BasicAuthEnabled,
28+
Username: cfg.BasicAuthUsername,
29+
Password: cfg.BasicAuthPassword,
30+
},
2431
}
2532
}
2633

27-
// RegisterRoutes registers all API routes
28-
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
29-
// Endpoint management
30-
mux.HandleFunc("/api/endpoints", h.handleEndpoints)
31-
mux.HandleFunc("/api/endpoints/", h.handleEndpointByName)
32-
mux.HandleFunc("/api/endpoints/current", h.handleCurrentEndpoint)
33-
mux.HandleFunc("/api/endpoints/switch", h.handleSwitchEndpoint)
34-
mux.HandleFunc("/api/endpoints/reorder", h.handleReorderEndpoints)
35-
mux.HandleFunc("/api/endpoints/fetch-models", h.handleFetchModels)
36-
37-
// Statistics
38-
mux.HandleFunc("/api/stats/summary", h.handleStatsSummary)
39-
mux.HandleFunc("/api/stats/daily", h.handleStatsDaily)
40-
mux.HandleFunc("/api/stats/weekly", h.handleStatsWeekly)
41-
mux.HandleFunc("/api/stats/monthly", h.handleStatsMonthly)
42-
mux.HandleFunc("/api/stats/trends", h.handleStatsTrends)
34+
// ServeHTTP implements http.Handler interface
35+
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
36+
path := r.URL.Path
37+
if !strings.HasPrefix(path, "/") {
38+
path = "/" + path
39+
}
4340

44-
// Configuration
45-
mux.HandleFunc("/api/config", h.handleConfig)
46-
mux.HandleFunc("/api/config/port", h.handleConfigPort)
47-
mux.HandleFunc("/api/config/log-level", h.handleConfigLogLevel)
41+
authMiddleware := BasicAuthMiddleware(h.auth)
4842

49-
// Real-time events
50-
mux.HandleFunc("/api/events", h.handleEvents)
51-
}
43+
switch path {
44+
case "/api/endpoints":
45+
authMiddleware(http.HandlerFunc(h.handleEndpoints)).ServeHTTP(w, r)
46+
case "/api/endpoints/current":
47+
authMiddleware(http.HandlerFunc(h.handleCurrentEndpoint)).ServeHTTP(w, r)
48+
case "/api/endpoints/switch":
49+
authMiddleware(http.HandlerFunc(h.handleSwitchEndpoint)).ServeHTTP(w, r)
50+
case "/api/endpoints/reorder":
51+
authMiddleware(http.HandlerFunc(h.handleReorderEndpoints)).ServeHTTP(w, r)
52+
case "/api/endpoints/fetch-models":
53+
authMiddleware(http.HandlerFunc(h.handleFetchModels)).ServeHTTP(w, r)
54+
case "/api/stats/summary":
55+
authMiddleware(http.HandlerFunc(h.handleStatsSummary)).ServeHTTP(w, r)
56+
case "/api/stats/daily":
57+
authMiddleware(http.HandlerFunc(h.handleStatsDaily)).ServeHTTP(w, r)
58+
case "/api/stats/weekly":
59+
authMiddleware(http.HandlerFunc(h.handleStatsWeekly)).ServeHTTP(w, r)
60+
case "/api/stats/monthly":
61+
authMiddleware(http.HandlerFunc(h.handleStatsMonthly)).ServeHTTP(w, r)
62+
case "/api/stats/trends":
63+
authMiddleware(http.HandlerFunc(h.handleStatsTrends)).ServeHTTP(w, r)
64+
case "/api/config":
65+
authMiddleware(http.HandlerFunc(h.handleConfig)).ServeHTTP(w, r)
66+
case "/api/config/port":
67+
authMiddleware(http.HandlerFunc(h.handleConfigPort)).ServeHTTP(w, r)
68+
case "/api/config/log-level":
69+
authMiddleware(http.HandlerFunc(h.handleConfigLogLevel)).ServeHTTP(w, r)
70+
case "/api/config/basic-auth":
71+
authMiddleware(http.HandlerFunc(h.handleBasicAuthConfig)).ServeHTTP(w, r)
72+
case "/api/config/basic-auth/reset-password":
73+
authMiddleware(http.HandlerFunc(h.handleResetBasicAuthPassword)).ServeHTTP(w, r)
74+
case "/api/events":
75+
authMiddleware(http.HandlerFunc(h.handleEvents)).ServeHTTP(w, r)
76+
default:
77+
if strings.HasPrefix(path, "/api/endpoints/") {
78+
authMiddleware(http.HandlerFunc(h.handleEndpointByName)).ServeHTTP(w, r)
79+
return
80+
}
81+
http.NotFound(w, r)
82+
}
83+
}

cmd/server/webui/api/middleware.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package api
22

33
import (
4+
"crypto/subtle"
5+
"encoding/base64"
46
"encoding/json"
57
"net/http"
8+
"strings"
69

710
"github.com/lich0821/ccNexus/internal/logger"
811
)
@@ -77,3 +80,62 @@ func LoggingMiddleware(next http.Handler) http.Handler {
7780
next.ServeHTTP(w, r)
7881
})
7982
}
83+
84+
type AuthConfig struct {
85+
Enabled bool
86+
Username string
87+
Password string
88+
}
89+
90+
func BasicAuthMiddleware(auth AuthConfig) func(http.Handler) http.Handler {
91+
return func(next http.Handler) http.Handler {
92+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+
if !auth.Enabled {
94+
next.ServeHTTP(w, r)
95+
return
96+
}
97+
98+
authHeader := r.Header.Get("Authorization")
99+
if authHeader == "" {
100+
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
101+
w.WriteHeader(http.StatusUnauthorized)
102+
return
103+
}
104+
105+
const prefix = "Basic "
106+
if !strings.HasPrefix(authHeader, prefix) {
107+
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
108+
w.WriteHeader(http.StatusUnauthorized)
109+
return
110+
}
111+
112+
encoded := strings.TrimPrefix(authHeader, prefix)
113+
decoded, err := base64.StdEncoding.DecodeString(encoded)
114+
if err != nil {
115+
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
116+
w.WriteHeader(http.StatusUnauthorized)
117+
return
118+
}
119+
120+
credentials := string(decoded)
121+
colonIndex := strings.Index(credentials, ":")
122+
if colonIndex < 0 {
123+
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
124+
w.WriteHeader(http.StatusUnauthorized)
125+
return
126+
}
127+
128+
username := credentials[:colonIndex]
129+
password := credentials[colonIndex+1:]
130+
131+
if subtle.ConstantTimeCompare([]byte(auth.Username), []byte(username)) != 1 ||
132+
subtle.ConstantTimeCompare([]byte(auth.Password), []byte(password)) != 1 {
133+
w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`)
134+
w.WriteHeader(http.StatusUnauthorized)
135+
return
136+
}
137+
138+
next.ServeHTTP(w, r)
139+
})
140+
}
141+
}

0 commit comments

Comments
 (0)