diff --git a/IP-WHITELIST-README.md b/IP-WHITELIST-README.md index 5b092af..d1c31c9 100644 --- a/IP-WHITELIST-README.md +++ b/IP-WHITELIST-README.md @@ -1,143 +1,214 @@ # IP 白名單功能說明 -## 功能概述 +## 概述 -本系統已實現完整的 IP 白名單功能,可以根據客戶端的 IP 地址來控制訪問權限。 +IP白名單功能允許你限制只有特定IP地址的用戶才能訪問你的網站。這是一個強大的安全功能,特別適用於內部工具或需要限制訪問的應用程式。 ## 功能特點 -### 1. IP 顯示 -- 在頁面右上角顯示當前用戶的 IP 地址 -- 支援桌面版和手機版的不同顯示方式 -- 顯示 IP 白名單狀態(允許/拒絕) +- ✅ **智能IP檢測**:自動檢測真實客戶端IP,支援代理伺服器 +- ✅ **多種IP格式**:支援單一IP、IP範圍(CIDR)、多個IP +- ✅ **地理位置信息**:可選的IP地理位置檢測 +- ✅ **調試工具**:內建IP檢測調試頁面 +- ✅ **靈活配置**:可隨時啟用/禁用白名單功能 -### 2. IP 白名單檢查 -- 支援多種 IP 格式: - - 單一 IP:`192.168.1.100` - - IP 範圍:`192.168.1.0/24` (CIDR 格式) - - 多個 IP:用逗號分隔,如 `192.168.1.100, 10.0.0.50` -- 實時檢查每個請求的 IP 地址 -- 不在白名單內的 IP 會被拒絕訪問並顯示 403 錯誤頁面 +## 快速開始 -### 3. 管理介面 -- 提供 `/admin/ip-whitelist` 管理頁面 -- 可以動態配置 IP 白名單 -- 顯示當前 IP 狀態和訪問權限 +### 1. 配置環境變數 -## 配置方法 +複製 `env.template` 為 `.env.local` 並配置以下變數: -### 1. 環境變數配置 - -在 `.env.local` 檔案中添加以下配置: - -```bash -# 允許訪問的IP地址或IP範圍,用逗號分隔 -ALLOWED_IPS=127.0.0.1,::1,192.168.1.0/24,10.0.0.0/8 +```env +# 允許的IP地址(用逗號分隔) +ALLOWED_IPS=114.33.18.13,125.229.65.83,60.248.164.91 # 是否啟用IP白名單檢查 -# true: 啟用IP檢查,不在白名單內的IP將被拒絕 -# false: 禁用IP檢查,允許所有IP訪問 ENABLE_IP_WHITELIST=true ``` -### 2. IP 格式說明 +### 2. 本地開發配置 -#### 單一 IP -``` -192.168.1.100 +在本地開發時,建議使用以下配置: + +```env +# 允許本地訪問 +ALLOWED_IPS=127.0.0.1,192.168.1.0/24 + +# 或者完全禁用IP檢查 +ENABLE_IP_WHITELIST=false ``` -#### IP 範圍 (CIDR 格式) -``` -192.168.1.0/24 # 允許 192.168.1.0 - 192.168.1.255 -10.0.0.0/8 # 允許 10.0.0.0 - 10.255.255.255 -172.16.0.0/12 # 允許 172.16.0.0 - 172.31.255.255 +### 3. 測試IP檢測 + +訪問 `/test/ip-debug` 頁面來測試IP檢測功能,查看: +- 檢測到的真實IP地址 +- 所有可能的IP來源 +- 地理位置信息 +- 白名單狀態 + +## IP 地址格式 + +### 支援的格式 + +1. **單一IP地址** + ``` + 192.168.1.100 + 114.33.18.13 + ``` + +2. **IP範圍(CIDR格式)** + ``` + 192.168.1.0/24 # 192.168.1.0 - 192.168.1.255 + 10.0.0.0/8 # 10.0.0.0 - 10.255.255.255 + 172.16.0.0/12 # 172.16.0.0 - 172.31.255.255 + ``` + +3. **多個IP地址** + ``` + 192.168.1.100,10.0.0.50,172.16.0.0/16 + ``` + +### 實際範例 + +使用 `allowed_ips.txt` 中的IP地址: + +```env +ALLOWED_IPS=114.33.18.13,125.229.65.83,60.248.164.91,220.132.236.89,211.72.69.222,219.87.170.253,125.228.50.228 ``` -#### 多個 IP 或範圍 -``` -192.168.1.100,10.0.0.50,172.16.0.0/16 +## 智能IP檢測 + +系統會自動檢測真實的客戶端IP地址,支援以下情況: + +### 代理伺服器支援 +- Cloudflare (`cf-connecting-ip`) +- Nginx (`x-real-ip`) +- Apache (`x-forwarded-for`) +- 其他常見代理頭 + +### 自動過濾 +- 排除本地回環地址 (`127.0.0.1`, `::1`) +- 優先選擇公網IP地址 +- 處理IPv6格式的IPv4地址 + +## 使用場景 + +### 1. 內部工具限制 +```env +# 只允許公司內部網路訪問 +ALLOWED_IPS=192.168.1.0/24,10.0.0.0/8 +ENABLE_IP_WHITELIST=true ``` -## 使用步驟 +### 2. 特定客戶訪問 +```env +# 只允許特定客戶IP訪問 +ALLOWED_IPS=203.0.113.1,198.51.100.50 +ENABLE_IP_WHITELIST=true +``` -### 1. 啟用 IP 白名單 -1. 設置 `ENABLE_IP_WHITELIST=true` -2. 在 `ALLOWED_IPS` 中配置允許的 IP 地址 -3. 重新啟動應用程式 +### 3. 開發環境 +```env +# 開發時允許所有IP +ENABLE_IP_WHITELIST=false +``` -### 2. 測試功能 -1. 訪問主頁面,查看右上角的 IP 顯示 -2. 使用不在白名單內的 IP 訪問,應該會看到 403 錯誤頁面 -3. 訪問 `/admin/ip-whitelist` 查看管理介面 +### 4. 生產環境安全 +```env +# 生產環境嚴格限制 +ALLOWED_IPS=114.33.18.13,125.229.65.83 +ENABLE_IP_WHITELIST=true +``` -### 3. 管理 IP 白名單 -1. 訪問 `/admin/ip-whitelist` 頁面 -2. 查看當前 IP 狀態 -3. 修改白名單設置 -4. 保存設置 +## 調試和故障排除 + +### 1. 使用調試頁面 +訪問 `/test/ip-debug` 查看詳細的IP檢測信息。 + +### 2. 常見問題 + +**問題:IP顯示為 127.0.0.1** +- 解決方案:在本地開發時,將 `127.0.0.1` 加入白名單或禁用IP檢查 + +**問題:無法訪問網站** +- 檢查:確認你的IP地址在白名單中 +- 檢查:確認 `ENABLE_IP_WHITELIST=true` 設置正確 + +**問題:代理伺服器環境** +- 系統會自動檢測代理轉發的真實IP +- 如果仍有問題,檢查代理伺服器配置 + +### 3. 日誌查看 +在瀏覽器開發者工具中查看網路請求,或檢查服務器日誌。 + +## 安全建議 + +1. **定期更新IP列表**:定期檢查和更新允許的IP地址 +2. **監控訪問日誌**:監控被拒絕的訪問嘗試 +3. **備用訪問方式**:考慮設定VPN或其他備用訪問方式 +4. **測試環境**:在測試環境中充分測試IP白名單功能 +5. **文檔記錄**:記錄所有允許的IP地址和用途 + +## API 端點 + +### GET /api/ip +返回當前客戶端的IP檢測信息: + +```json +{ + "ip": "203.0.113.1", + "isAllowed": true, + "enableIpWhitelist": true, + "allowedIps": ["114.33.18.13", "125.229.65.83"], + "timestamp": "2024-01-01T12:00:00.000Z", + "debug": { + "allIpSources": { + "x-forwarded-for": "203.0.113.1", + "x-real-ip": null, + // ... 其他IP來源 + }, + "environment": "production", + "host": "example.com", + "referer": "https://example.com/" + }, + "location": { + "country": "Taiwan", + "city": "Taipei", + "isp": "Chunghwa Telecom" + } +} +``` + +## 部署注意事項 + +### Vercel 部署 +1. 在 Vercel 項目設置中配置環境變數 +2. 確保 `ENABLE_IP_WHITELIST` 設置正確 +3. 測試IP檢測功能是否正常工作 + +### 其他平台 +1. 確保環境變數正確配置 +2. 測試代理伺服器IP轉發是否正常 +3. 檢查防火牆和網路配置 + +## 相關文件 + +- `allowed_ips.txt` - IP地址清單文件 +- `lib/ip-utils.ts` - IP檢測工具函數 +- `middleware.ts` - IP白名單中間件 +- `app/api/ip/route.ts` - IP檢測API +- `app/test/ip-debug/page.tsx` - IP調試頁面 ## 技術實現 -### 1. 中間件 (middleware.ts) -- 攔截所有請求 -- 檢查客戶端 IP 是否在白名單內 -- 拒絕不在白名單內的訪問 +### 核心組件 +- **IP檢測**:`lib/ip-utils.ts` 中的 `getClientIp()` 函數 +- **白名單檢查**:`isIpAllowed()` 函數支援CIDR範圍 +- **中間件**:`middleware.ts` 在請求處理前檢查IP +- **API端點**:`/api/ip` 提供IP檢測服務 -### 2. IP 工具函數 (lib/ip-utils.ts) -- `isIpAllowed()`: 檢查 IP 是否被允許 -- `getClientIp()`: 獲取客戶端真實 IP -- `isIpInRange()`: 檢查 IP 是否在指定範圍內 - -### 3. API 路由 (app/api/ip/route.ts) -- 提供 IP 信息查詢接口 -- 返回當前 IP 和白名單狀態 - -### 4. IP 顯示組件 (components/ip-display.tsx) -- 顯示當前 IP 地址 -- 顯示白名單狀態 -- 支援響應式設計 - -## 安全注意事項 - -1. **IP 偽造防護**:系統會檢查多個 IP 來源頭,包括: - - `x-forwarded-for` - - `x-real-ip` - - `x-client-ip` - - `cf-connecting-ip` (Cloudflare) - -2. **本地開發**:開發環境中,`127.0.0.1` 和 `::1` 通常會被自動允許 - -3. **代理環境**:如果使用反向代理,請確保正確配置 IP 轉發 - -## 故障排除 - -### 1. IP 顯示不正確 -- 檢查是否在代理環境中 -- 確認 `getClientIp()` 函數的 IP 來源配置 - -### 2. 白名單不生效 -- 確認 `ENABLE_IP_WHITELIST=true` -- 檢查 `ALLOWED_IPS` 格式是否正確 -- 重新啟動應用程式 - -### 3. 403 錯誤頁面 -- 檢查當前 IP 是否在白名單內 -- 查看瀏覽器開發者工具中的網路請求 -- 檢查伺服器日誌 - -## 開發建議 - -1. **測試環境**:建議在測試環境中先配置 `ENABLE_IP_WHITELIST=false` -2. **IP 範圍**:使用 CIDR 格式可以更靈活地管理 IP 範圍 -3. **日誌記錄**:系統會記錄被拒絕的訪問,便於排查問題 -4. **備份配置**:建議備份 IP 白名單配置,避免誤操作 - -## 相關檔案 - -- `env.template` - 環境變數模板 -- `middleware.ts` - Next.js 中間件 -- `lib/ip-utils.ts` - IP 工具函數 -- `app/api/ip/route.ts` - IP 信息 API -- `components/ip-display.tsx` - IP 顯示組件 -- `app/admin/ip-whitelist/page.tsx` - 管理頁面 \ No newline at end of file +### 安全機制 +- 服務器端檢查,客戶端無法繞過 +- 支援多種代理伺服器配置 +- 自動過濾無效IP地址 +- 詳細的訪問日誌記錄 \ No newline at end of file diff --git a/app/api/ip/route.ts b/app/api/ip/route.ts index 1babc49..ec9af33 100644 --- a/app/api/ip/route.ts +++ b/app/api/ip/route.ts @@ -1,23 +1,57 @@ import { NextRequest, NextResponse } from 'next/server' -import { getClientIp, isIpAllowed } from '@/lib/ip-utils' +import { getClientIp, isIpAllowed, getIpLocation, getDetailedIpInfo } from '@/lib/ip-utils' // 強制動態渲染 export const dynamic = 'force-dynamic' export async function GET(request: NextRequest) { try { - const clientIp = getClientIp(request) + // 使用詳細的IP檢測功能 + const detailedInfo = getDetailedIpInfo(request); + const clientIp = detailedInfo.detectedIp; + const allowedIps = process.env.ALLOWED_IPS || '' const enableIpWhitelist = process.env.ENABLE_IP_WHITELIST === 'true' const isAllowed = enableIpWhitelist ? isIpAllowed(clientIp, allowedIps) : true + // 嘗試獲取地理位置信息 + let locationInfo = null + if (clientIp && clientIp !== '127.0.0.1' && isPublicIp(clientIp)) { + try { + locationInfo = await getIpLocation(clientIp) + } catch (error) { + console.error('Error fetching location info:', error) + } + } + return NextResponse.json({ ip: clientIp, isAllowed, enableIpWhitelist, allowedIps: enableIpWhitelist ? allowedIps.split(',').map(ip => ip.trim()) : [], - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + debug: { + allIpSources: detailedInfo.ipSources, + allFoundIps: detailedInfo.allFoundIps, + isLocalDevelopment: detailedInfo.isLocalDevelopment, + localIp: detailedInfo.localIp, + environment: process.env.NODE_ENV, + host: request.headers.get('host'), + referer: request.headers.get('referer'), + userAgent: request.headers.get('user-agent'), + }, + location: locationInfo, + // 本地開發環境的特殊信息 + development: detailedInfo.isLocalDevelopment ? { + message: '本地開發環境 - IP檢測可能受限', + suggestions: [ + '在生產環境中部署後,IP檢測會更準確', + '可以使用 ngrok 或類似工具進行外部測試', + '檢查防火牆和網路配置', + '確認代理伺服器設置' + ] + } : null }) } catch (error) { console.error('Error getting IP info:', error) @@ -26,4 +60,20 @@ export async function GET(request: NextRequest) { { status: 500 } ) } +} + +// 檢查是否為公網IP的輔助函數 +function isPublicIp(ip: string): boolean { + const privateRanges = [ + /^10\./, // 10.0.0.0/8 + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^127\./, // 127.0.0.0/8 + /^169\.254\./, // 169.254.0.0/16 (Link-local) + /^0\./, // 0.0.0.0/8 + /^224\./, // 224.0.0.0/4 (Multicast) + /^240\./, // 240.0.0.0/4 (Reserved) + ]; + + return !privateRanges.some(range => range.test(ip)); } \ No newline at end of file diff --git a/app/test/ip-debug/page.tsx b/app/test/ip-debug/page.tsx new file mode 100644 index 0000000..f86a17c --- /dev/null +++ b/app/test/ip-debug/page.tsx @@ -0,0 +1,336 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Globe, Shield, MapPin, RefreshCw, AlertCircle, CheckCircle, Info, Lightbulb } from 'lucide-react' + +interface IpDebugInfo { + ip: string + isAllowed: boolean + enableIpWhitelist: boolean + allowedIps: string[] + timestamp: string + debug: { + allIpSources: Record + allFoundIps: string[] + isLocalDevelopment: boolean + localIp: string | null + environment: string + host: string | null + referer: string | null + userAgent: string | null + } + location: any + development: { + message: string + suggestions: string[] + } | null +} + +export default function IpDebugPage() { + const [ipInfo, setIpInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchIpInfo = async () => { + setLoading(true) + setError(null) + try { + const response = await fetch('/api/ip') + if (!response.ok) { + throw new Error('無法獲取IP信息') + } + const data = await response.json() + setIpInfo(data) + } catch (error) { + console.error("無法獲取IP信息:", error) + setError("無法獲取IP信息") + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchIpInfo() + }, []) + + if (loading) { + return ( +
+
+
+ + 載入中... +
+
+
+ ) + } + + if (error || !ipInfo) { + return ( +
+ + + + + 錯誤 + + + +

{error || '無法獲取IP信息'}

+ +
+
+
+ ) + } + + return ( +
+
+

IP 檢測調試工具

+

查看詳細的IP檢測信息和調試數據

+
+ + {/* 本地開發環境提示 */} + {ipInfo.development && ( + + + +
{ipInfo.development.message}
+
+ {ipInfo.development.suggestions.map((suggestion, index) => ( +
+ + {suggestion} +
+ ))} +
+
+
+ )} + + {/* 主要IP信息 */} + + + + + IP 信息 + + + +
+
+ 檢測到的IP: + + {ipInfo.ip} + +
+
+ 狀態: + {ipInfo.isAllowed ? ( + + + 允許 + + ) : ( + + + 拒絕 + + )} +
+
+ +
+
+ + IP白名單: + + {ipInfo.enableIpWhitelist ? '已啟用' : '已停用'} + +
+
+ 環境: + {ipInfo.debug.environment} +
+ {ipInfo.debug.isLocalDevelopment && ipInfo.debug.localIp && ( +
+ 本機IP: + {ipInfo.debug.localIp} +
+ )} +
+ + {ipInfo.location && ( +
+ + 位置: + + {ipInfo.location.city}, {ipInfo.location.country} ({ipInfo.location.isp}) + +
+ )} +
+
+ + {/* 所有找到的IP */} + + + 所有檢測到的IP + 系統檢測到的所有IP地址 + + +
+ {ipInfo.debug.allFoundIps.length > 0 ? ( + ipInfo.debug.allFoundIps.map((ip, index) => ( + + {ip} {ip === ipInfo.ip && '(已選擇)'} + + )) + ) : ( + 未檢測到任何IP + )} +
+
+
+ + {/* 允許的IP列表 */} + {ipInfo.enableIpWhitelist && ( + + + 允許的IP地址 + 當前配置的IP白名單 + + +
+ {ipInfo.allowedIps.length > 0 ? ( + ipInfo.allowedIps.map((ip, index) => ( + + {ip} + + )) + ) : ( + 未配置IP白名單 + )} +
+
+
+ )} + + {/* 調試信息 */} + + + 調試信息 + 所有可能的IP來源和請求頭信息 + + +
+
+

所有IP來源:

+
+ {Object.entries(ipInfo.debug.allIpSources).map(([key, value]) => ( +
+ {key}: + + {value || 'null'} + +
+ ))} +
+
+ + + +
+

請求信息:

+
+
+ Host: + + {ipInfo.debug.host || 'null'} + +
+
+ Referer: + + {ipInfo.debug.referer || 'null'} + +
+
+ User Agent: + + {ipInfo.debug.userAgent || 'null'} + +
+
+ 時間戳: + + {new Date(ipInfo.timestamp).toLocaleString('zh-TW')} + +
+
+
+
+
+
+ + {/* 地理位置信息 */} + {ipInfo.location && ( + + + + + 地理位置信息 + + + +
+
+

位置信息

+
+
國家: {ipInfo.location.country} ({ipInfo.location.countryCode})
+
地區: {ipInfo.location.regionName}
+
城市: {ipInfo.location.city}
+
郵遞區號: {ipInfo.location.zip}
+
時區: {ipInfo.location.timezone}
+
+
+
+

網路信息

+
+
ISP: {ipInfo.location.isp}
+
組織: {ipInfo.location.org}
+
AS: {ipInfo.location.as}
+
行動網路: {ipInfo.location.mobile ? '是' : '否'}
+
代理: {ipInfo.location.proxy ? '是' : '否'}
+
主機服務: {ipInfo.location.hosting ? '是' : '否'}
+
+
+
+
+
+ )} + + {/* 操作按鈕 */} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/app/test/ip-test/page.tsx b/app/test/ip-test/page.tsx new file mode 100644 index 0000000..460a2b1 --- /dev/null +++ b/app/test/ip-test/page.tsx @@ -0,0 +1,196 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Globe, ExternalLink, Info, CheckCircle, AlertCircle } from 'lucide-react' + +export default function IpTestPage() { + const [externalIp, setExternalIp] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchExternalIp = async () => { + setLoading(true) + setError(null) + try { + const response = await fetch('https://api.ipify.org?format=json') + if (!response.ok) { + throw new Error('無法獲取外部IP') + } + const data = await response.json() + setExternalIp(data.ip) + } catch (error) { + console.error('Error fetching external IP:', error) + setError('無法獲取外部IP地址') + } finally { + setLoading(false) + } + } + + return ( +
+
+

IP 測試工具

+

測試你的真實公網IP地址

+
+ + {/* 說明 */} + + + +
為什麼會顯示 127.0.0.1?
+
+
• 在本地開發環境中,所有請求都來自本地回環地址
+
• 這是正常的行為,不是錯誤
+
• 在生產環境中部署後,IP檢測會更準確
+
• 你可以使用下面的工具測試你的真實公網IP
+
+
+
+ + {/* 外部IP檢測 */} + + + + + 真實公網IP檢測 + + + 從外部服務獲取你的真實公網IP地址 + + + +
+ + + {externalIp && ( +
+ 你的公網IP: + + + {externalIp} + +
+ )} +
+ + {error && ( + + + {error} + + )} + +
+

這個IP地址就是你的真實公網IP,可以用於:

+
    +
  • 配置IP白名單
  • +
  • 測試IP檢測功能
  • +
  • 驗證網路配置
  • +
+
+
+
+ + {/* 測試方法 */} + + + 測試真實IP的方法 + 幾種測試IP檢測功能的方法 + + +
+
+

1. 使用 ngrok 進行外部測試

+
+

• 安裝 ngrok: npm install -g ngrok

+

• 啟動隧道: ngrok http 3000

+

• 使用 ngrok 提供的URL訪問你的應用

+

• 這樣就能測試真實的IP檢測功能

+
+
+ +
+

2. 部署到生產環境

+
+

• 部署到 Vercel、Netlify 或其他平台

+

• 在生產環境中,IP檢測會更準確

+

• 可以測試真實的IP白名單功能

+
+
+ +
+

3. 使用代理服務器

+
+

• 配置 Nginx 或 Apache 作為反向代理

+

• 確保正確設置 IP 轉發頭

+

• 這樣可以模擬生產環境的IP檢測

+
+
+
+
+
+ + {/* 配置建議 */} + + + 配置建議 + 根據不同環境的配置建議 + + +
+
+

本地開發環境

+
+

.env.local 中設置:

+
+{`# 禁用IP白名單檢查
+ENABLE_IP_WHITELIST=false
+
+# 或者允許本地IP
+ALLOWED_IPS=127.0.0.1,192.168.1.0/24`}
+                
+
+
+ +
+

生產環境

+
+

在生產環境中設置:

+
+{`# 啟用IP白名單
+ENABLE_IP_WHITELIST=true
+
+# 設置允許的IP地址
+ALLOWED_IPS=你的真實IP地址,其他允許的IP`}
+                
+
+
+
+
+
+ + {/* 快速連結 */} +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/env.template b/env.template index e849a5c..522a140 100644 --- a/env.template +++ b/env.template @@ -42,7 +42,15 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here # - IP範圍: 192.168.1.0/24 # - 多個IP: 192.168.1.100,10.0.0.50,172.16.0.0/16 # 留空表示允許所有IP訪問 -ALLOWED_IPS=192.168.1.0/24,10.0.0.0/8,172.16.0.0/12 + +# 範例:使用 allowed_ips.txt 中的IP地址 +# ALLOWED_IPS=114.33.18.13,125.229.65.83,60.248.164.91,220.132.236.89,211.72.69.222,219.87.170.253,125.228.50.228 + +# 範例:本地開發(允許本地網路) +# ALLOWED_IPS=127.0.0.1,192.168.1.0/24,10.0.0.0/8 + +# 範例:生產環境(只允許特定IP) +ALLOWED_IPS=114.33.18.13,125.229.65.83,60.248.164.91,220.132.236.89,211.72.69.222,219.87.170.253,125.228.50.228 # 是否啟用IP白名單檢查 # true: 啟用IP檢查,不在白名單內的IP將被拒絕 @@ -55,6 +63,15 @@ ENABLE_IP_WHITELIST=true # 啟用詳細日誌 # NEXT_PUBLIC_DEBUG=true +# ================================ +# IP 白名單說明 +# ================================ +# 1. 本地開發時,建議設置 ENABLE_IP_WHITELIST=false 或包含 127.0.0.1 +# 2. 生產環境部署後,設置實際的IP地址並啟用白名單 +# 3. 可以使用 /test/ip-debug 頁面測試IP檢測功能 +# 4. IP檢測會自動處理代理伺服器轉發的真實IP +# 5. 支援 IPv4 和 CIDR 格式的IP範圍 + # ================================ # 注意事項 # ================================ diff --git a/lib/ip-utils.ts b/lib/ip-utils.ts index a2b4e01..d15b55e 100644 --- a/lib/ip-utils.ts +++ b/lib/ip-utils.ts @@ -54,30 +54,132 @@ export function isIpAllowed(clientIp: string, allowedIps: string): boolean { return false; } +// 清理和驗證IP地址 +function cleanIpAddress(ip: string): string | null { + if (!ip) return null; + + // 移除空白字符 + ip = ip.trim(); + + // 處理IPv6格式的IPv4地址 (例如: ::ffff:192.168.1.1) + if (ip.startsWith('::ffff:')) { + ip = ip.substring(7); + } + + // 驗證IP格式 + if (!isValidIp(ip)) { + return null; + } + + return ip; +} + // 獲取客戶端真實IP export function getClientIp(req: any): string { - // 檢查各種可能的IP來源 + // 按優先順序檢查各種IP來源 const ipSources = [ + // 代理伺服器轉發的IP req.headers['x-forwarded-for'], req.headers['x-real-ip'], req.headers['x-client-ip'], req.headers['cf-connecting-ip'], // Cloudflare + req.headers['x-forwarded'], // 舊版代理頭 + req.headers['forwarded-for'], + req.headers['forwarded'], + + // 直接連接的IP req.connection?.remoteAddress, req.socket?.remoteAddress, - req.ip + req.ip, + + // Next.js 特定的IP來源 + req.headers['x-original-forwarded-for'], + req.headers['x-cluster-client-ip'], ]; + // 收集所有找到的IP用於調試 + const foundIps: string[] = []; + for (const ipSource of ipSources) { if (ipSource) { - // 處理多個IP的情況 (例如: "192.168.1.1, 10.0.0.1") - const firstIp = ipSource.toString().split(',')[0].trim(); - if (firstIp && firstIp !== '::1' && firstIp !== '127.0.0.1') { - return firstIp; + // 處理多個IP的情況 (例如: "192.168.1.1, 10.0.0.1, 203.0.113.1") + const ipList = ipSource.toString().split(',').map(ip => ip.trim()); + + // 遍歷所有IP,找到第一個有效的公網IP + for (const ip of ipList) { + const cleanIp = cleanIpAddress(ip); + if (cleanIp) { + foundIps.push(cleanIp); + // 檢查是否為公網IP + if (isPublicIp(cleanIp)) { + return cleanIp; + } + } } } } - return '127.0.0.1'; // 默認本地IP + // 如果沒有找到有效的公網IP,返回第一個有效的IP + for (const ipSource of ipSources) { + if (ipSource) { + const ipList = ipSource.toString().split(',').map(ip => ip.trim()); + for (const ip of ipList) { + const cleanIp = cleanIpAddress(ip); + if (cleanIp) { + return cleanIp; + } + } + } + } + + // 在本地開發環境中,嘗試獲取本機IP + if (process.env.NODE_ENV === 'development') { + const localIp = getLocalIp(); + if (localIp && localIp !== '127.0.0.1') { + return localIp; + } + } + + // 最後的備用方案 + return '127.0.0.1'; +} + +// 獲取本機IP地址(僅用於開發環境) +function getLocalIp(): string | null { + try { + const os = require('os'); + const interfaces = os.networkInterfaces(); + + for (const name of Object.keys(interfaces)) { + for (const networkInterface of interfaces[name]) { + // 跳過內部(非IPv4)和回環地址 + if (networkInterface.family === 'IPv4' && !networkInterface.internal) { + return networkInterface.address; + } + } + } + } catch (error) { + console.error('Error getting local IP:', error); + } + + return null; +} + +// 檢查是否為公網IP +function isPublicIp(ip: string): boolean { + // 私有IP範圍 + const privateRanges = [ + /^10\./, // 10.0.0.0/8 + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^127\./, // 127.0.0.0/8 + /^169\.254\./, // 169.254.0.0/16 (Link-local) + /^0\./, // 0.0.0.0/8 + /^224\./, // 224.0.0.0/4 (Multicast) + /^240\./, // 240.0.0.0/4 (Reserved) + ]; + + return !privateRanges.some(range => range.test(ip)); } // 驗證IP地址格式 @@ -90,4 +192,86 @@ export function isValidIp(ip: string): boolean { export function isValidCidr(cidr: string): boolean { const cidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/([0-9]|[1-2][0-9]|3[0-2])$/; return cidrRegex.test(cidr); +} + +// 獲取IP地理位置信息(可選功能) +export async function getIpLocation(ip: string): Promise { + try { + const response = await fetch(`http://ip-api.com/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone,isp,org,as,mobile,proxy,hosting,query`); + if (response.ok) { + return await response.json(); + } + } catch (error) { + console.error('Error fetching IP location:', error); + } + return null; +} + +// 獲取詳細的IP檢測信息(用於調試) +export function getDetailedIpInfo(req: any): { + detectedIp: string; + allFoundIps: string[]; + ipSources: Record; + isLocalDevelopment: boolean; + localIp: string | null; +} { + const ipSources = { + 'x-forwarded-for': req.headers['x-forwarded-for'], + 'x-real-ip': req.headers['x-real-ip'], + 'x-client-ip': req.headers['x-client-ip'], + 'cf-connecting-ip': req.headers['cf-connecting-ip'], + 'x-forwarded': req.headers['x-forwarded'], + 'forwarded-for': req.headers['forwarded-for'], + 'forwarded': req.headers['forwarded'], + 'x-original-forwarded-for': req.headers['x-original-forwarded-for'], + 'x-cluster-client-ip': req.headers['x-cluster-client-ip'], + 'connection.remoteAddress': req.connection?.remoteAddress, + 'socket.remoteAddress': req.socket?.remoteAddress, + 'req.ip': req.ip, + }; + + const allFoundIps: string[] = []; + let detectedIp = '127.0.0.1'; + + // 收集所有IP + Object.values(ipSources).forEach(ipSource => { + if (ipSource) { + const ipList = ipSource.toString().split(',').map(ip => ip.trim()); + ipList.forEach(ip => { + const cleanIp = cleanIpAddress(ip); + if (cleanIp && !allFoundIps.includes(cleanIp)) { + allFoundIps.push(cleanIp); + } + }); + } + }); + + // 選擇最佳IP + for (const ip of allFoundIps) { + if (isPublicIp(ip)) { + detectedIp = ip; + break; + } + } + + // 如果沒有公網IP,選擇第一個非回環IP + if (detectedIp === '127.0.0.1') { + for (const ip of allFoundIps) { + if (ip !== '127.0.0.1' && ip !== '::1') { + detectedIp = ip; + break; + } + } + } + + const isLocalDevelopment = process.env.NODE_ENV === 'development'; + const localIp = isLocalDevelopment ? getLocalIp() : null; + + return { + detectedIp, + allFoundIps, + ipSources, + isLocalDevelopment, + localIp + }; } \ No newline at end of file diff --git a/scripts/test-ip-detection.js b/scripts/test-ip-detection.js new file mode 100644 index 0000000..1195248 --- /dev/null +++ b/scripts/test-ip-detection.js @@ -0,0 +1,160 @@ +/** + * IP 檢測功能測試腳本 + * 用於測試和驗證IP白名單功能 + */ + +const { getClientIp, isIpAllowed, isValidIp, isValidCidr } = require('../lib/ip-utils.ts'); + +// 模擬請求對象 +function createMockRequest(headers = {}) { + return { + headers, + connection: { remoteAddress: '192.168.1.100' }, + socket: { remoteAddress: '192.168.1.100' }, + ip: '192.168.1.100' + }; +} + +// 測試IP檢測功能 +function testIpDetection() { + console.log('🧪 開始測試IP檢測功能...\n'); + + // 測試1: 基本IP檢測 + console.log('📋 測試1: 基本IP檢測'); + const basicRequest = createMockRequest({ + 'x-forwarded-for': '203.0.113.1, 192.168.1.100' + }); + const detectedIp = getClientIp(basicRequest); + console.log(`檢測到的IP: ${detectedIp}`); + console.log(`預期結果: 203.0.113.1 (公網IP)`); + console.log(`實際結果: ${detectedIp === '203.0.113.1' ? '✅ 通過' : '❌ 失敗'}\n`); + + // 測試2: Cloudflare代理 + console.log('📋 測試2: Cloudflare代理'); + const cloudflareRequest = createMockRequest({ + 'cf-connecting-ip': '203.0.113.2' + }); + const cfIp = getClientIp(cloudflareRequest); + console.log(`檢測到的IP: ${cfIp}`); + console.log(`預期結果: 203.0.113.2`); + console.log(`實際結果: ${cfIp === '203.0.113.2' ? '✅ 通過' : '❌ 失敗'}\n`); + + // 測試3: 本地開發環境 + console.log('📋 測試3: 本地開發環境'); + const localRequest = createMockRequest({ + 'x-forwarded-for': '127.0.0.1' + }); + const localIp = getClientIp(localRequest); + console.log(`檢測到的IP: ${localIp}`); + console.log(`預期結果: 127.0.0.1 (本地回環)`); + console.log(`實際結果: ${localIp === '127.0.0.1' ? '✅ 通過' : '❌ 失敗'}\n`); + + // 測試4: 多個代理IP + console.log('📋 測試4: 多個代理IP'); + const multiProxyRequest = createMockRequest({ + 'x-forwarded-for': '203.0.113.3, 10.0.0.1, 192.168.1.1' + }); + const multiIp = getClientIp(multiProxyRequest); + console.log(`檢測到的IP: ${multiIp}`); + console.log(`預期結果: 203.0.113.3 (第一個公網IP)`); + console.log(`實際結果: ${multiIp === '203.0.113.3' ? '✅ 通過' : '❌ 失敗'}\n`); +} + +// 測試IP白名單功能 +function testIpWhitelist() { + console.log('🔒 開始測試IP白名單功能...\n'); + + const allowedIps = '114.33.18.13,125.229.65.83,192.168.1.0/24'; + + // 測試1: 單一IP匹配 + console.log('📋 測試1: 單一IP匹配'); + const testIp1 = '114.33.18.13'; + const result1 = isIpAllowed(testIp1, allowedIps); + console.log(`測試IP: ${testIp1}`); + console.log(`預期結果: true (在白名單中)`); + console.log(`實際結果: ${result1 ? '✅ 通過' : '❌ 失敗'}\n`); + + // 測試2: CIDR範圍匹配 + console.log('📋 測試2: CIDR範圍匹配'); + const testIp2 = '192.168.1.100'; + const result2 = isIpAllowed(testIp2, allowedIps); + console.log(`測試IP: ${testIp2}`); + console.log(`預期結果: true (在192.168.1.0/24範圍內)`); + console.log(`實際結果: ${result2 ? '✅ 通過' : '❌ 失敗'}\n`); + + // 測試3: 不在白名單中的IP + console.log('📋 測試3: 不在白名單中的IP'); + const testIp3 = '203.0.113.1'; + const result3 = isIpAllowed(testIp3, allowedIps); + console.log(`測試IP: ${testIp3}`); + console.log(`預期結果: false (不在白名單中)`); + console.log(`實際結果: ${!result3 ? '✅ 通過' : '❌ 失敗'}\n`); + + // 測試4: 空白名單 + console.log('📋 測試4: 空白名單'); + const testIp4 = '203.0.113.1'; + const result4 = isIpAllowed(testIp4, ''); + console.log(`測試IP: ${testIp4}`); + console.log(`預期結果: true (空白名單允許所有IP)`); + console.log(`實際結果: ${result4 ? '✅ 通過' : '❌ 失敗'}\n`); +} + +// 測試IP格式驗證 +function testIpValidation() { + console.log('🔍 開始測試IP格式驗證...\n'); + + // 測試有效IP + console.log('📋 測試有效IP格式'); + const validIps = ['192.168.1.1', '10.0.0.1', '203.0.113.1']; + validIps.forEach(ip => { + const isValid = isValidIp(ip); + console.log(`${ip}: ${isValid ? '✅ 有效' : '❌ 無效'}`); + }); + console.log(''); + + // 測試無效IP + console.log('📋 測試無效IP格式'); + const invalidIps = ['192.168.1.256', '10.0.0', 'invalid', '192.168.1.1.1']; + invalidIps.forEach(ip => { + const isValid = isValidIp(ip); + console.log(`${ip}: ${!isValid ? '✅ 正確拒絕' : '❌ 錯誤接受'}`); + }); + console.log(''); + + // 測試CIDR格式 + console.log('📋 測試CIDR格式'); + const validCidrs = ['192.168.1.0/24', '10.0.0.0/8', '172.16.0.0/12']; + validCidrs.forEach(cidr => { + const isValid = isValidCidr(cidr); + console.log(`${cidr}: ${isValid ? '✅ 有效' : '❌ 無效'}`); + }); + console.log(''); +} + +// 主測試函數 +function runAllTests() { + console.log('🚀 IP白名單功能測試套件\n'); + console.log('=' * 50); + + try { + testIpDetection(); + testIpWhitelist(); + testIpValidation(); + + console.log('🎉 所有測試完成!'); + } catch (error) { + console.error('❌ 測試過程中發生錯誤:', error); + } +} + +// 如果直接運行此腳本 +if (require.main === module) { + runAllTests(); +} + +module.exports = { + testIpDetection, + testIpWhitelist, + testIpValidation, + runAllTests +}; \ No newline at end of file