diff --git a/data/config.json b/data/config.json index c02e22b..e2061a9 100644 --- a/data/config.json +++ b/data/config.json @@ -35,36 +35,5 @@ "TargetHost": "cdn.jsdelivr.net", "TargetURL": "https://cdn.jsdelivr.net" } - ], - "Metrics": { - "Password": "admin123", - "TokenExpiry": 86400, - "FeishuWebhook": "https://open.feishu.cn/open-apis/bot/v2/hook/****", - "Alert": { - "WindowSize": 12, - "WindowInterval": "5m", - "DedupeWindow": "15m", - "MinRequests": 10, - "ErrorRate": 0.8, - "AlertInterval": "24h" - }, - "Latency": { - "SmallFileSize": 1048576, - "MediumFileSize": 10485760, - "LargeFileSize": 104857600, - "SmallLatency": "3s", - "MediumLatency": "8s", - "LargeLatency": "30s", - "HugeLatency": "300s" - }, - "Performance": { - "MaxRequestsPerMinute": 1000, - "MaxBytesPerMinute": 104857600, - "MaxSaveInterval": "15m" - }, - "Validation": { - "max_error_rate": 0.8, - "max_data_deviation": 0.01 - } - } + ] } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1efe3ad..7c2d388 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,17 +8,6 @@ services: - ./data:/app/data environment: - TZ=Asia/Shanghai - restart: always - deploy: - resources: - limits: - cpus: '1' - memory: 512M - reservations: - cpus: '0.25' - memory: 128M - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:3336/"] - interval: 30s - timeout: 3s - retries: 3 \ No newline at end of file + - OAUTH_CLIENT_ID=your_client_id + - OAUTH_ALLOWED_USERS=user1,user2,user3 + restart: always \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 32bd125..5044c94 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,7 +77,6 @@ func (c *configImpl) Update(newConfig *Config) { c.MAP = newConfig.MAP c.Compression = newConfig.Compression c.FixedPaths = newConfig.FixedPaths - c.Metrics = newConfig.Metrics // 触发回调 for _, callback := range c.onConfigUpdate { diff --git a/internal/config/types.go b/internal/config/types.go index c370430..c711ac2 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -9,7 +9,6 @@ type Config struct { MAP map[string]PathConfig `json:"MAP"` // 改为使用PathConfig Compression CompressionConfig `json:"Compression"` FixedPaths []FixedPathConfig `json:"FixedPaths"` - Metrics MetricsConfig `json:"Metrics"` } type PathConfig struct { @@ -35,12 +34,6 @@ type FixedPathConfig struct { TargetURL string `json:"TargetURL"` } -// MetricsConfig 监控配置 -type MetricsConfig struct { - Password string `json:"Password"` // 管理密码 - TokenExpiry int `json:"TokenExpiry"` // Token过期时间(秒) -} - // 添加一个辅助方法来处理字符串到 PathConfig 的转换 func (c *Config) UnmarshalJSON(data []byte) error { // 创建一个临时结构来解析原始JSON @@ -48,7 +41,6 @@ func (c *Config) UnmarshalJSON(data []byte) error { MAP map[string]json.RawMessage `json:"MAP"` Compression CompressionConfig `json:"Compression"` FixedPaths []FixedPathConfig `json:"FixedPaths"` - Metrics MetricsConfig `json:"Metrics"` } var temp TempConfig @@ -84,7 +76,6 @@ func (c *Config) UnmarshalJSON(data []byte) error { // 复制其他字段 c.Compression = temp.Compression c.FixedPaths = temp.FixedPaths - c.Metrics = temp.Metrics return nil } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 3000052..a4056c8 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -4,26 +4,51 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "fmt" "log" "net/http" + "net/url" + "os" "proxy-go/internal/utils" "strings" "sync" "time" ) +const ( + tokenExpiry = 30 * 24 * time.Hour // Token 过期时间为 30 天 +) + +type OAuthUserInfo struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + Admin bool `json:"admin"` + Moderator bool `json:"moderator"` + Groups []string `json:"groups"` +} + +type OAuthToken struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + type tokenInfo struct { createdAt time.Time expiresIn time.Duration + username string } type authManager struct { tokens sync.Map + states sync.Map } func newAuthManager() *authManager { am := &authManager{} - // 启动token清理goroutine go am.cleanExpiredTokens() return am } @@ -34,10 +59,11 @@ func (am *authManager) generateToken() string { return base64.URLEncoding.EncodeToString(b) } -func (am *authManager) addToken(token string, expiry time.Duration) { +func (am *authManager) addToken(token string, username string, expiry time.Duration) { am.tokens.Store(token, tokenInfo{ createdAt: time.Now(), expiresIn: expiry, + username: username, }) } @@ -112,42 +138,115 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { } } -// AuthHandler 处理认证请求 -func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - log.Printf("[Auth] ERR %s %s -> 405 (%s) method not allowed", r.Method, r.URL.Path, utils.GetClientIP(r)) - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return +// getCallbackURL 从请求中获取回调地址 +func getCallbackURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" } - - // 解析表单数据 - if err := r.ParseForm(); err != nil { - log.Printf("[Auth] ERR %s %s -> 400 (%s) form parse error", r.Method, r.URL.Path, utils.GetClientIP(r)) - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - password := r.FormValue("password") - if password == "" { - log.Printf("[Auth] ERR %s %s -> 400 (%s) empty password", r.Method, r.URL.Path, utils.GetClientIP(r)) - http.Error(w, "Password is required", http.StatusBadRequest) - return - } - - if password != h.config.Metrics.Password { - log.Printf("[Auth] ERR %s %s -> 401 (%s) invalid password", r.Method, r.URL.Path, utils.GetClientIP(r)) - http.Error(w, "Invalid password", http.StatusUnauthorized) - return - } - - token := h.auth.generateToken() - h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second) - - log.Printf("[Auth] %s %s -> 200 (%s) login success", r.Method, r.URL.Path, utils.GetClientIP(r)) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "token": token, - }) + return fmt.Sprintf("%s://%s/admin/api/oauth/callback", scheme, r.Host) +} + +// LoginHandler 处理登录请求,重定向到 OAuth 授权页面 +func (h *ProxyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { + state := h.auth.generateToken() + h.auth.states.Store(state, time.Now()) + + clientID := os.Getenv("OAUTH_CLIENT_ID") + redirectURI := getCallbackURL(r) + + authURL := fmt.Sprintf("https://connect.q58.club/oauth/authorize?%s", + url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + "state": {state}, + }.Encode()) + + http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) +} + +// isAllowedUser 检查用户是否在允许列表中 +func isAllowedUser(username string) bool { + allowedUsers := strings.Split(os.Getenv("OAUTH_ALLOWED_USERS"), ",") + for _, allowed := range allowedUsers { + if strings.TrimSpace(allowed) == username { + return true + } + } + return false +} + +// OAuthCallbackHandler 处理 OAuth 回调 +func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + // 验证 state + if _, ok := h.auth.states.Load(state); !ok { + http.Error(w, "Invalid state", http.StatusBadRequest) + return + } + h.auth.states.Delete(state) + + // 获取访问令牌 + redirectURI := getCallbackURL(r) + resp, err := http.PostForm("https://connect.q58.club/api/oauth/access_token", + url.Values{ + "code": {code}, + "redirect_uri": {redirectURI}, + }) + if err != nil { + http.Error(w, "Failed to get access token", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + var token OAuthToken + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + http.Error(w, "Failed to parse token response", http.StatusInternalServerError) + return + } + + // 获取用户信息 + req, _ := http.NewRequest("GET", "https://connect.q58.club/api/oauth/user", nil) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + client := &http.Client{} + userResp, err := client.Do(req) + if err != nil { + http.Error(w, "Failed to get user info", http.StatusInternalServerError) + return + } + defer userResp.Body.Close() + + var userInfo OAuthUserInfo + if err := json.NewDecoder(userResp.Body).Decode(&userInfo); err != nil { + http.Error(w, "Failed to parse user info", http.StatusInternalServerError) + return + } + + // 检查用户是否在允许列表中 + if !isAllowedUser(userInfo.Username) { + http.Error(w, "Unauthorized user", http.StatusUnauthorized) + return + } + + // 生成内部访问令牌 + internalToken := h.auth.generateToken() + h.auth.addToken(internalToken, userInfo.Username, tokenExpiry) + + // 返回登录成功页面 + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` + + 登录成功 + + + + + `, internalToken, userInfo.Username) } diff --git a/main.go b/main.go index 29f2e7d..84f274f 100644 --- a/main.go +++ b/main.go @@ -57,8 +57,14 @@ func main() { if strings.HasPrefix(r.URL.Path, "/admin/api/") { switch r.URL.Path { case "/admin/api/auth": - if r.Method == http.MethodPost { - proxyHandler.AuthHandler(w, r) + if r.Method == http.MethodGet { + proxyHandler.LoginHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case "/admin/api/oauth/callback": + if r.Method == http.MethodGet { + proxyHandler.OAuthCallbackHandler(w, r) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index 88539c5..91cacae 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -1,60 +1,11 @@ "use client" -import { useState } from "react" -import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { useToast } from "@/components/ui/use-toast" export default function LoginPage() { - const [password, setPassword] = useState("") - const [loading, setLoading] = useState(false) - const router = useRouter() - const { toast } = useToast() - - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault() - setLoading(true) - - try { - const response = await fetch("/admin/api/auth", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: `password=${encodeURIComponent(password)}`, - }) - - if (!response.ok) { - throw new Error("登录失败") - } - - const data = await response.json() - localStorage.setItem("token", data.token) - - // 验证token - const verifyResponse = await fetch("/admin/api/check-auth", { - headers: { - 'Authorization': `Bearer ${data.token}`, - }, - }); - - if (verifyResponse.ok) { - // 登录成功,跳转到仪表盘 - router.push("/dashboard") - } else { - throw new Error("Token验证失败") - } - } catch (error) { - toast({ - title: "错误", - description: error instanceof Error ? error.message : "登录失败", - variant: "destructive", - }) - } finally { - setLoading(false) - } + const handleLogin = () => { + window.location.href = "/admin/api/auth" } return ( @@ -64,24 +15,11 @@ export default function LoginPage() { 管理员登录 -
-
- - setPassword(e.target.value)} - placeholder="请输入管理密码" - required - /> -
- -
+