mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 16:41:54 +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",
|
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -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
|
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
10
main.go
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user