Files
Mirror-Proxy/internal/auth/auth.go
T
AgentandHAPI d6623fc150
Build and Push to GHCR / build-and-push (push) Has been cancelled
feat: per-link IP whitelist, clipboard fallback, and link editing
- Add `allowed_ips` to Link model with IPv4/IPv6 and CIDR support
- Validate client IP in proxy auth middleware against link whitelist
- Extract client IP from X-Forwarded-For / X-Real-Ip headers
- Fix copy button for non-HTTPS contexts via execCommand fallback
- Allow editing existing links (name, type, auth mode, rate limit, IPs)
- Add dedicated IP whitelist modal for quick editing

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <[email protected]>
2026-05-24 17:14:35 +08:00

250 lines
4.9 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"net"
"net/http"
"strings"
"sync"
"time"
"mirror-proxy/internal/config"
)
type RateLimiter struct {
visitors map[string]*visitor
mu sync.RWMutex
}
type visitor struct {
count int
lastSeen time.Time
}
func NewRateLimiter() *RateLimiter {
rl := &RateLimiter{visitors: make(map[string]*visitor)}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
rl.mu.Lock()
for ip, v := range rl.visitors {
if time.Since(v.lastSeen) > time.Minute {
delete(rl.visitors, ip)
}
}
rl.mu.Unlock()
}
}
func (rl *RateLimiter) Allow(ip string, limit int) bool {
if limit <= 0 {
return true
}
rl.mu.Lock()
defer rl.mu.Unlock()
v, exists := rl.visitors[ip]
if !exists {
rl.visitors[ip] = &visitor{count: 1, lastSeen: time.Now()}
return true
}
if time.Since(v.lastSeen) > time.Minute {
v.count = 1
v.lastSeen = time.Now()
return true
}
if v.count >= limit {
return false
}
v.count++
v.lastSeen = time.Now()
return true
}
func GenerateToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func getAuthMode(link *config.Link) string {
if link.AuthMode == "" {
return "dual"
}
return link.AuthMode
}
// CheckIPAllowed 检查客户端 IP 是否在链接的白名单中
func CheckIPAllowed(clientIP string, allowedIPs []string) bool {
if len(allowedIPs) == 0 {
return true
}
ip := net.ParseIP(clientIP)
if ip == nil {
// 尝试去掉端口
host, _, err := net.SplitHostPort(clientIP)
if err == nil {
ip = net.ParseIP(host)
}
if ip == nil {
return false
}
}
for _, allowed := range allowedIPs {
allowed = strings.TrimSpace(allowed)
if allowed == "" {
continue
}
// CIDR 格式
if strings.Contains(allowed, "/") {
_, ipNet, err := net.ParseCIDR(allowed)
if err == nil && ipNet.Contains(ip) {
return true
}
continue
}
// 单个 IP
allowedIP := net.ParseIP(allowed)
if allowedIP != nil && allowedIP.Equal(ip) {
return true
}
}
return false
}
// extractClientIP 从请求中提取客户端 IP(去掉端口)
func extractClientIP(r *http.Request) string {
ip := r.RemoteAddr
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
ip = strings.Split(xf, ",")[0]
} else if xr := r.Header.Get("X-Real-Ip"); xr != "" {
ip = xr
}
ip = strings.TrimSpace(ip)
host, _, err := net.SplitHostPort(ip)
if err == nil {
ip = host
}
return ip
}
func ValidateLinkToken(linkID, token string) (*config.Link, bool) {
cfg := config.Get()
link, ok := cfg.GetLink(linkID)
if !ok {
return nil, false
}
if !link.Enabled {
return nil, false
}
if getAuthMode(link) != "dual" {
return nil, false
}
if link.Token != token {
return nil, false
}
return link, true
}
func ValidateLinkTokenSingle(token string) (*config.Link, bool) {
cfg := config.Get()
link, ok := cfg.GetLinkByToken(token)
if !ok {
return nil, false
}
if !link.Enabled {
return nil, false
}
if getAuthMode(link) != "single" {
return nil, false
}
return link, true
}
type contextKey string
const LinkContextKey contextKey = "link"
const TokenPrefixContextKey contextKey = "tokenPrefix"
func ProxyAuthMiddleware(rl *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 1 {
http.Error(w, "Unauthorized: missing token", http.StatusUnauthorized)
return
}
var link *config.Link
var ok bool
var newPath string
var tokenPrefix string
// 优先尝试 dual 模式 (/{linkID}/{token}/...)
if len(parts) >= 2 {
link, ok = ValidateLinkToken(parts[0], parts[1])
if ok {
tokenPrefix = "/" + parts[0] + "/" + parts[1]
if len(parts) == 2 {
newPath = "/"
} else {
newPath = "/" + parts[2]
}
}
}
// 尝试 single 模式 (/{token}/...)
if !ok {
link, ok = ValidateLinkTokenSingle(parts[0])
if ok {
tokenPrefix = "/" + parts[0]
if len(parts) == 1 {
newPath = "/"
} else {
newPath = "/" + strings.Join(parts[1:], "/")
}
}
}
if !ok {
http.Error(w, "Unauthorized: invalid link or token", http.StatusUnauthorized)
return
}
clientIP := extractClientIP(r)
if !CheckIPAllowed(clientIP, link.AllowedIPs) {
http.Error(w, "Forbidden: IP not allowed", http.StatusForbidden)
return
}
if !rl.Allow(clientIP, link.RateLimit) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
r.URL.Path = newPath
// 存储link和token前缀到context
ctx := context.WithValue(r.Context(), LinkContextKey, link)
ctx = context.WithValue(ctx, TokenPrefixContextKey, tokenPrefix)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}