mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 08:31:55 +08:00
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:
parent
621900d227
commit
a4437b9a39
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -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
|
||||
- OAUTH_CLIENT_ID=your_client_id
|
||||
- OAUTH_ALLOWED_USERS=user1,user2,user3
|
||||
restart: always
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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, `
|
||||
<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
10
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)
|
||||
}
|
||||
|
@ -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() {
|
||||
<CardTitle className="text-2xl text-center">管理员登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
密码
|
||||
</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 ? "登录中..." : "登录"}
|
||||
<div className="space-y-4">
|
||||
<Button onClick={handleLogin} className="w-full">
|
||||
使用 Q58 账号登录
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
Loading…
x
Reference in New Issue
Block a user