feat(auth): Implement OAuth-based authentication with Q58 platform

- Replace password-based login with OAuth authentication
- Add OAuth login and callback handlers
- Support user whitelist via environment variables
- Update login page to use OAuth flow
- Remove legacy metrics-related authentication configuration
- Enhance token management with username tracking
This commit is contained in:
wood chen 2025-02-17 05:43:23 +08:00
parent 621900d227
commit a4437b9a39
7 changed files with 156 additions and 165 deletions

View File

@ -35,36 +35,5 @@
"TargetHost": "cdn.jsdelivr.net", "TargetHost": "cdn.jsdelivr.net",
"TargetURL": "https://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
}
}
} }

View File

@ -8,17 +8,6 @@ services:
- ./data:/app/data - ./data:/app/data
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- OAUTH_CLIENT_ID=your_client_id
- OAUTH_ALLOWED_USERS=user1,user2,user3
restart: always 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

View File

@ -77,7 +77,6 @@ func (c *configImpl) Update(newConfig *Config) {
c.MAP = newConfig.MAP c.MAP = newConfig.MAP
c.Compression = newConfig.Compression c.Compression = newConfig.Compression
c.FixedPaths = newConfig.FixedPaths c.FixedPaths = newConfig.FixedPaths
c.Metrics = newConfig.Metrics
// 触发回调 // 触发回调
for _, callback := range c.onConfigUpdate { for _, callback := range c.onConfigUpdate {

View File

@ -9,7 +9,6 @@ type Config struct {
MAP map[string]PathConfig `json:"MAP"` // 改为使用PathConfig MAP map[string]PathConfig `json:"MAP"` // 改为使用PathConfig
Compression CompressionConfig `json:"Compression"` Compression CompressionConfig `json:"Compression"`
FixedPaths []FixedPathConfig `json:"FixedPaths"` FixedPaths []FixedPathConfig `json:"FixedPaths"`
Metrics MetricsConfig `json:"Metrics"`
} }
type PathConfig struct { type PathConfig struct {
@ -35,12 +34,6 @@ type FixedPathConfig struct {
TargetURL string `json:"TargetURL"` TargetURL string `json:"TargetURL"`
} }
// MetricsConfig 监控配置
type MetricsConfig struct {
Password string `json:"Password"` // 管理密码
TokenExpiry int `json:"TokenExpiry"` // Token过期时间(秒)
}
// 添加一个辅助方法来处理字符串到 PathConfig 的转换 // 添加一个辅助方法来处理字符串到 PathConfig 的转换
func (c *Config) UnmarshalJSON(data []byte) error { func (c *Config) UnmarshalJSON(data []byte) error {
// 创建一个临时结构来解析原始JSON // 创建一个临时结构来解析原始JSON
@ -48,7 +41,6 @@ func (c *Config) UnmarshalJSON(data []byte) error {
MAP map[string]json.RawMessage `json:"MAP"` MAP map[string]json.RawMessage `json:"MAP"`
Compression CompressionConfig `json:"Compression"` Compression CompressionConfig `json:"Compression"`
FixedPaths []FixedPathConfig `json:"FixedPaths"` FixedPaths []FixedPathConfig `json:"FixedPaths"`
Metrics MetricsConfig `json:"Metrics"`
} }
var temp TempConfig var temp TempConfig
@ -84,7 +76,6 @@ func (c *Config) UnmarshalJSON(data []byte) error {
// 复制其他字段 // 复制其他字段
c.Compression = temp.Compression c.Compression = temp.Compression
c.FixedPaths = temp.FixedPaths c.FixedPaths = temp.FixedPaths
c.Metrics = temp.Metrics
return nil return nil
} }

View File

@ -4,26 +4,51 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"os"
"proxy-go/internal/utils" "proxy-go/internal/utils"
"strings" "strings"
"sync" "sync"
"time" "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 { type tokenInfo struct {
createdAt time.Time createdAt time.Time
expiresIn time.Duration expiresIn time.Duration
username string
} }
type authManager struct { type authManager struct {
tokens sync.Map tokens sync.Map
states sync.Map
} }
func newAuthManager() *authManager { func newAuthManager() *authManager {
am := &authManager{} am := &authManager{}
// 启动token清理goroutine
go am.cleanExpiredTokens() go am.cleanExpiredTokens()
return am return am
} }
@ -34,10 +59,11 @@ func (am *authManager) generateToken() string {
return base64.URLEncoding.EncodeToString(b) 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{ am.tokens.Store(token, tokenInfo{
createdAt: time.Now(), createdAt: time.Now(),
expiresIn: expiry, expiresIn: expiry,
username: username,
}) })
} }
@ -112,42 +138,115 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
} }
} }
// AuthHandler 处理认证请求 // getCallbackURL 从请求中获取回调地址
func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { func getCallbackURL(r *http.Request) string {
if r.Method != http.MethodPost { scheme := "http"
log.Printf("[Auth] ERR %s %s -> 405 (%s) method not allowed", r.Method, r.URL.Path, utils.GetClientIP(r)) if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) scheme = "https"
return }
return fmt.Sprintf("%s://%s/admin/api/oauth/callback", scheme, r.Host)
} }
// 解析表单数据 // LoginHandler 处理登录请求,重定向到 OAuth 授权页面
if err := r.ParseForm(); err != nil { func (h *ProxyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("[Auth] ERR %s %s -> 400 (%s) form parse error", r.Method, r.URL.Path, utils.GetClientIP(r)) state := h.auth.generateToken()
http.Error(w, "Invalid request", http.StatusBadRequest) h.auth.states.Store(state, time.Now())
return
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)
} }
password := r.FormValue("password") // isAllowedUser 检查用户是否在允许列表中
if password == "" { func isAllowedUser(username string) bool {
log.Printf("[Auth] ERR %s %s -> 400 (%s) empty password", r.Method, r.URL.Path, utils.GetClientIP(r)) allowedUsers := strings.Split(os.Getenv("OAUTH_ALLOWED_USERS"), ",")
http.Error(w, "Password is required", http.StatusBadRequest) for _, allowed := range allowedUsers {
return if strings.TrimSpace(allowed) == username {
return true
}
}
return false
} }
if password != h.config.Metrics.Password { // OAuthCallbackHandler 处理 OAuth 回调
log.Printf("[Auth] ERR %s %s -> 401 (%s) invalid password", r.Method, r.URL.Path, utils.GetClientIP(r)) func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid password", http.StatusUnauthorized) 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 return
} }
h.auth.states.Delete(state)
token := h.auth.generateToken() // 获取访问令牌
h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second) redirectURI := getCallbackURL(r)
resp, err := http.PostForm("https://connect.q58.club/api/oauth/access_token",
log.Printf("[Auth] %s %s -> 200 (%s) login success", r.Method, r.URL.Path, utils.GetClientIP(r)) url.Values{
"code": {code},
w.Header().Set("Content-Type", "application/json") "redirect_uri": {redirectURI},
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"token": token,
}) })
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, `
<html>
<head><title>登录成功</title></head>
<body>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('user', '%s');
window.location.href = '/admin';
</script>
</body>
</html>
`, internalToken, userInfo.Username)
} }

10
main.go
View File

@ -57,8 +57,14 @@ func main() {
if strings.HasPrefix(r.URL.Path, "/admin/api/") { if strings.HasPrefix(r.URL.Path, "/admin/api/") {
switch r.URL.Path { switch r.URL.Path {
case "/admin/api/auth": case "/admin/api/auth":
if r.Method == http.MethodPost { if r.Method == http.MethodGet {
proxyHandler.AuthHandler(w, r) 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 { } else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
} }

View File

@ -1,60 +1,11 @@
"use client" "use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
export default function LoginPage() { export default function LoginPage() {
const [password, setPassword] = useState("") const handleLogin = () => {
const [loading, setLoading] = useState(false) window.location.href = "/admin/api/auth"
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)
}
} }
return ( return (
@ -64,24 +15,11 @@ export default function LoginPage() {
<CardTitle className="text-2xl text-center"></CardTitle> <CardTitle className="text-2xl text-center"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleLogin} className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <Button onClick={handleLogin} className="w-full">
<label htmlFor="password" className="text-sm font-medium"> 使 Q58
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入管理密码"
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button> </Button>
</form> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>