做 ip 防呆機制
This commit is contained in:
@@ -1,143 +1,214 @@
|
|||||||
# IP 白名單功能說明
|
# IP 白名單功能說明
|
||||||
|
|
||||||
## 功能概述
|
## 概述
|
||||||
|
|
||||||
本系統已實現完整的 IP 白名單功能,可以根據客戶端的 IP 地址來控制訪問權限。
|
IP白名單功能允許你限制只有特定IP地址的用戶才能訪問你的網站。這是一個強大的安全功能,特別適用於內部工具或需要限制訪問的應用程式。
|
||||||
|
|
||||||
## 功能特點
|
## 功能特點
|
||||||
|
|
||||||
### 1. IP 顯示
|
- ✅ **智能IP檢測**:自動檢測真實客戶端IP,支援代理伺服器
|
||||||
- 在頁面右上角顯示當前用戶的 IP 地址
|
- ✅ **多種IP格式**:支援單一IP、IP範圍(CIDR)、多個IP
|
||||||
- 支援桌面版和手機版的不同顯示方式
|
- ✅ **地理位置信息**:可選的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. 管理介面
|
### 1. 配置環境變數
|
||||||
- 提供 `/admin/ip-whitelist` 管理頁面
|
|
||||||
- 可以動態配置 IP 白名單
|
|
||||||
- 顯示當前 IP 狀態和訪問權限
|
|
||||||
|
|
||||||
## 配置方法
|
複製 `env.template` 為 `.env.local` 並配置以下變數:
|
||||||
|
|
||||||
### 1. 環境變數配置
|
```env
|
||||||
|
# 允許的IP地址(用逗號分隔)
|
||||||
在 `.env.local` 檔案中添加以下配置:
|
ALLOWED_IPS=114.33.18.13,125.229.65.83,60.248.164.91
|
||||||
|
|
||||||
```bash
|
|
||||||
# 允許訪問的IP地址或IP範圍,用逗號分隔
|
|
||||||
ALLOWED_IPS=127.0.0.1,::1,192.168.1.0/24,10.0.0.0/8
|
|
||||||
|
|
||||||
# 是否啟用IP白名單檢查
|
# 是否啟用IP白名單檢查
|
||||||
# true: 啟用IP檢查,不在白名單內的IP將被拒絕
|
|
||||||
# false: 禁用IP檢查,允許所有IP訪問
|
|
||||||
ENABLE_IP_WHITELIST=true
|
ENABLE_IP_WHITELIST=true
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. IP 格式說明
|
### 2. 本地開發配置
|
||||||
|
|
||||||
#### 單一 IP
|
在本地開發時,建議使用以下配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 允許本地訪問
|
||||||
|
ALLOWED_IPS=127.0.0.1,192.168.1.0/24
|
||||||
|
|
||||||
|
# 或者完全禁用IP檢查
|
||||||
|
ENABLE_IP_WHITELIST=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 測試IP檢測
|
||||||
|
|
||||||
|
訪問 `/test/ip-debug` 頁面來測試IP檢測功能,查看:
|
||||||
|
- 檢測到的真實IP地址
|
||||||
|
- 所有可能的IP來源
|
||||||
|
- 地理位置信息
|
||||||
|
- 白名單狀態
|
||||||
|
|
||||||
|
## IP 地址格式
|
||||||
|
|
||||||
|
### 支援的格式
|
||||||
|
|
||||||
|
1. **單一IP地址**
|
||||||
```
|
```
|
||||||
192.168.1.100
|
192.168.1.100
|
||||||
|
114.33.18.13
|
||||||
```
|
```
|
||||||
|
|
||||||
#### IP 範圍 (CIDR 格式)
|
2. **IP範圍(CIDR格式)**
|
||||||
```
|
```
|
||||||
192.168.1.0/24 # 允許 192.168.1.0 - 192.168.1.255
|
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
|
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
|
172.16.0.0/12 # 172.16.0.0 - 172.31.255.255
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 多個 IP 或範圍
|
3. **多個IP地址**
|
||||||
```
|
```
|
||||||
192.168.1.100,10.0.0.50,172.16.0.0/16
|
192.168.1.100,10.0.0.50,172.16.0.0/16
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用步驟
|
### 實際範例
|
||||||
|
|
||||||
### 1. 啟用 IP 白名單
|
使用 `allowed_ips.txt` 中的IP地址:
|
||||||
1. 設置 `ENABLE_IP_WHITELIST=true`
|
|
||||||
2. 在 `ALLOWED_IPS` 中配置允許的 IP 地址
|
|
||||||
3. 重新啟動應用程式
|
|
||||||
|
|
||||||
### 2. 測試功能
|
```env
|
||||||
1. 訪問主頁面,查看右上角的 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
|
||||||
2. 使用不在白名單內的 IP 訪問,應該會看到 403 錯誤頁面
|
```
|
||||||
3. 訪問 `/admin/ip-whitelist` 查看管理介面
|
|
||||||
|
|
||||||
### 3. 管理 IP 白名單
|
## 智能IP檢測
|
||||||
1. 訪問 `/admin/ip-whitelist` 頁面
|
|
||||||
2. 查看當前 IP 狀態
|
系統會自動檢測真實的客戶端IP地址,支援以下情況:
|
||||||
3. 修改白名單設置
|
|
||||||
4. 保存設置
|
### 代理伺服器支援
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 開發環境
|
||||||
|
```env
|
||||||
|
# 開發時允許所有IP
|
||||||
|
ENABLE_IP_WHITELIST=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 生產環境安全
|
||||||
|
```env
|
||||||
|
# 生產環境嚴格限制
|
||||||
|
ALLOWED_IPS=114.33.18.13,125.229.65.83
|
||||||
|
ENABLE_IP_WHITELIST=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 調試和故障排除
|
||||||
|
|
||||||
|
### 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檢測**:`lib/ip-utils.ts` 中的 `getClientIp()` 函數
|
||||||
- 檢查客戶端 IP 是否在白名單內
|
- **白名單檢查**:`isIpAllowed()` 函數支援CIDR範圍
|
||||||
- 拒絕不在白名單內的訪問
|
- **中間件**:`middleware.ts` 在請求處理前檢查IP
|
||||||
|
- **API端點**:`/api/ip` 提供IP檢測服務
|
||||||
|
|
||||||
### 2. IP 工具函數 (lib/ip-utils.ts)
|
### 安全機制
|
||||||
- `isIpAllowed()`: 檢查 IP 是否被允許
|
- 服務器端檢查,客戶端無法繞過
|
||||||
- `getClientIp()`: 獲取客戶端真實 IP
|
- 支援多種代理伺服器配置
|
||||||
- `isIpInRange()`: 檢查 IP 是否在指定範圍內
|
- 自動過濾無效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` - 管理頁面
|
|
@@ -1,23 +1,57 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
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 const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const clientIp = getClientIp(request)
|
// 使用詳細的IP檢測功能
|
||||||
|
const detailedInfo = getDetailedIpInfo(request);
|
||||||
|
const clientIp = detailedInfo.detectedIp;
|
||||||
|
|
||||||
const allowedIps = process.env.ALLOWED_IPS || ''
|
const allowedIps = process.env.ALLOWED_IPS || ''
|
||||||
const enableIpWhitelist = process.env.ENABLE_IP_WHITELIST === 'true'
|
const enableIpWhitelist = process.env.ENABLE_IP_WHITELIST === 'true'
|
||||||
|
|
||||||
const isAllowed = enableIpWhitelist ? isIpAllowed(clientIp, allowedIps) : 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({
|
return NextResponse.json({
|
||||||
ip: clientIp,
|
ip: clientIp,
|
||||||
isAllowed,
|
isAllowed,
|
||||||
enableIpWhitelist,
|
enableIpWhitelist,
|
||||||
allowedIps: enableIpWhitelist ? allowedIps.split(',').map(ip => ip.trim()) : [],
|
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) {
|
} catch (error) {
|
||||||
console.error('Error getting IP info:', error)
|
console.error('Error getting IP info:', error)
|
||||||
@@ -27,3 +61,19 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 檢查是否為公網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));
|
||||||
|
}
|
336
app/test/ip-debug/page.tsx
Normal file
336
app/test/ip-debug/page.tsx
Normal file
@@ -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<string, string | null>
|
||||||
|
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<IpDebugInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="container mx-auto p-6 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
|
<span>載入中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !ipInfo) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 max-w-4xl">
|
||||||
|
<Card className="border-red-200 bg-red-50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-red-800">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
錯誤
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-red-700">{error || '無法獲取IP信息'}</p>
|
||||||
|
<Button onClick={fetchIpInfo} className="mt-4">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
重試
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 max-w-4xl space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">IP 檢測調試工具</h1>
|
||||||
|
<p className="text-muted-foreground">查看詳細的IP檢測信息和調試數據</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 本地開發環境提示 */}
|
||||||
|
{ipInfo.development && (
|
||||||
|
<Alert className="border-blue-200 bg-blue-50">
|
||||||
|
<Info className="h-4 w-4 text-blue-600" />
|
||||||
|
<AlertDescription className="text-blue-800">
|
||||||
|
<div className="font-medium mb-2">{ipInfo.development.message}</div>
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
{ipInfo.development.suggestions.map((suggestion, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-2">
|
||||||
|
<Lightbulb className="w-3 h-3 mt-0.5 text-blue-500 flex-shrink-0" />
|
||||||
|
<span>{suggestion}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主要IP信息 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5" />
|
||||||
|
IP 信息
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">檢測到的IP:</span>
|
||||||
|
<Badge variant={ipInfo.ip === '127.0.0.1' ? 'destructive' : 'default'}>
|
||||||
|
{ipInfo.ip}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">狀態:</span>
|
||||||
|
{ipInfo.isAllowed ? (
|
||||||
|
<Badge variant="default" className="flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
允許
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive" className="flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
拒絕
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span className="text-sm">IP白名單:</span>
|
||||||
|
<Badge variant={ipInfo.enableIpWhitelist ? 'default' : 'secondary'}>
|
||||||
|
{ipInfo.enableIpWhitelist ? '已啟用' : '已停用'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">環境:</span>
|
||||||
|
<Badge variant="outline">{ipInfo.debug.environment}</Badge>
|
||||||
|
</div>
|
||||||
|
{ipInfo.debug.isLocalDevelopment && ipInfo.debug.localIp && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">本機IP:</span>
|
||||||
|
<Badge variant="outline">{ipInfo.debug.localIp}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ipInfo.location && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span className="text-sm">位置:</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
{ipInfo.location.city}, {ipInfo.location.country} ({ipInfo.location.isp})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 所有找到的IP */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>所有檢測到的IP</CardTitle>
|
||||||
|
<CardDescription>系統檢測到的所有IP地址</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ipInfo.debug.allFoundIps.length > 0 ? (
|
||||||
|
ipInfo.debug.allFoundIps.map((ip, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant={ip === ipInfo.ip ? 'default' : 'outline'}
|
||||||
|
className={ip === '127.0.0.1' ? 'border-red-300 text-red-700' : ''}
|
||||||
|
>
|
||||||
|
{ip} {ip === ipInfo.ip && '(已選擇)'}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">未檢測到任何IP</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 允許的IP列表 */}
|
||||||
|
{ipInfo.enableIpWhitelist && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>允許的IP地址</CardTitle>
|
||||||
|
<CardDescription>當前配置的IP白名單</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ipInfo.allowedIps.length > 0 ? (
|
||||||
|
ipInfo.allowedIps.map((ip, index) => (
|
||||||
|
<Badge key={index} variant="outline">
|
||||||
|
{ip}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">未配置IP白名單</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 調試信息 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>調試信息</CardTitle>
|
||||||
|
<CardDescription>所有可能的IP來源和請求頭信息</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">所有IP來源:</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{Object.entries(ipInfo.debug.allIpSources).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex justify-between items-center p-2 bg-muted rounded">
|
||||||
|
<span className="text-sm font-mono">{key}:</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{value || 'null'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">請求信息:</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm">Host:</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{ipInfo.debug.host || 'null'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm">Referer:</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{ipInfo.debug.referer || 'null'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm">User Agent:</span>
|
||||||
|
<span className="text-sm text-muted-foreground max-w-xs truncate">
|
||||||
|
{ipInfo.debug.userAgent || 'null'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm">時間戳:</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{new Date(ipInfo.timestamp).toLocaleString('zh-TW')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 地理位置信息 */}
|
||||||
|
{ipInfo.location && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
地理位置信息
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">位置信息</h4>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div>國家: {ipInfo.location.country} ({ipInfo.location.countryCode})</div>
|
||||||
|
<div>地區: {ipInfo.location.regionName}</div>
|
||||||
|
<div>城市: {ipInfo.location.city}</div>
|
||||||
|
<div>郵遞區號: {ipInfo.location.zip}</div>
|
||||||
|
<div>時區: {ipInfo.location.timezone}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">網路信息</h4>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div>ISP: {ipInfo.location.isp}</div>
|
||||||
|
<div>組織: {ipInfo.location.org}</div>
|
||||||
|
<div>AS: {ipInfo.location.as}</div>
|
||||||
|
<div>行動網路: {ipInfo.location.mobile ? '是' : '否'}</div>
|
||||||
|
<div>代理: {ipInfo.location.proxy ? '是' : '否'}</div>
|
||||||
|
<div>主機服務: {ipInfo.location.hosting ? '是' : '否'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按鈕 */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button onClick={fetchIpInfo} className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
重新檢測IP
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
196
app/test/ip-test/page.tsx
Normal file
196
app/test/ip-test/page.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="container mx-auto p-6 max-w-4xl space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">IP 測試工具</h1>
|
||||||
|
<p className="text-muted-foreground">測試你的真實公網IP地址</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 說明 */}
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="font-medium mb-2">為什麼會顯示 127.0.0.1?</div>
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<div>• 在本地開發環境中,所有請求都來自本地回環地址</div>
|
||||||
|
<div>• 這是正常的行為,不是錯誤</div>
|
||||||
|
<div>• 在生產環境中部署後,IP檢測會更準確</div>
|
||||||
|
<div>• 你可以使用下面的工具測試你的真實公網IP</div>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 外部IP檢測 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5" />
|
||||||
|
真實公網IP檢測
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
從外部服務獲取你的真實公網IP地址
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={fetchExternalIp}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
{loading ? '檢測中...' : '檢測真實IP'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{externalIp && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">你的公網IP:</span>
|
||||||
|
<Badge variant="default" className="flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
{externalIp}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>這個IP地址就是你的真實公網IP,可以用於:</p>
|
||||||
|
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||||
|
<li>配置IP白名單</li>
|
||||||
|
<li>測試IP檢測功能</li>
|
||||||
|
<li>驗證網路配置</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 測試方法 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>測試真實IP的方法</CardTitle>
|
||||||
|
<CardDescription>幾種測試IP檢測功能的方法</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">1. 使用 ngrok 進行外部測試</h4>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<p>• 安裝 ngrok: <code className="bg-muted px-1 rounded">npm install -g ngrok</code></p>
|
||||||
|
<p>• 啟動隧道: <code className="bg-muted px-1 rounded">ngrok http 3000</code></p>
|
||||||
|
<p>• 使用 ngrok 提供的URL訪問你的應用</p>
|
||||||
|
<p>• 這樣就能測試真實的IP檢測功能</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">2. 部署到生產環境</h4>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<p>• 部署到 Vercel、Netlify 或其他平台</p>
|
||||||
|
<p>• 在生產環境中,IP檢測會更準確</p>
|
||||||
|
<p>• 可以測試真實的IP白名單功能</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">3. 使用代理服務器</h4>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<p>• 配置 Nginx 或 Apache 作為反向代理</p>
|
||||||
|
<p>• 確保正確設置 IP 轉發頭</p>
|
||||||
|
<p>• 這樣可以模擬生產環境的IP檢測</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 配置建議 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>配置建議</CardTitle>
|
||||||
|
<CardDescription>根據不同環境的配置建議</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">本地開發環境</h4>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>在 <code className="bg-muted px-1 rounded">.env.local</code> 中設置:</p>
|
||||||
|
<pre className="bg-muted p-2 rounded mt-2 text-xs">
|
||||||
|
{`# 禁用IP白名單檢查
|
||||||
|
ENABLE_IP_WHITELIST=false
|
||||||
|
|
||||||
|
# 或者允許本地IP
|
||||||
|
ALLOWED_IPS=127.0.0.1,192.168.1.0/24`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">生產環境</h4>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>在生產環境中設置:</p>
|
||||||
|
<pre className="bg-muted p-2 rounded mt-2 text-xs">
|
||||||
|
{`# 啟用IP白名單
|
||||||
|
ENABLE_IP_WHITELIST=true
|
||||||
|
|
||||||
|
# 設置允許的IP地址
|
||||||
|
ALLOWED_IPS=你的真實IP地址,其他允許的IP`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 快速連結 */}
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<Button variant="outline" onClick={() => window.open('/test/ip-debug', '_blank')}>
|
||||||
|
<Globe className="w-4 h-4 mr-2" />
|
||||||
|
詳細IP調試
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => window.open('/api/ip', '_blank')}>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
IP API 端點
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
19
env.template
19
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.0/24
|
||||||
# - 多個IP: 192.168.1.100,10.0.0.50,172.16.0.0/16
|
# - 多個IP: 192.168.1.100,10.0.0.50,172.16.0.0/16
|
||||||
# 留空表示允許所有IP訪問
|
# 留空表示允許所有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白名單檢查
|
# 是否啟用IP白名單檢查
|
||||||
# true: 啟用IP檢查,不在白名單內的IP將被拒絕
|
# true: 啟用IP檢查,不在白名單內的IP將被拒絕
|
||||||
@@ -55,6 +63,15 @@ ENABLE_IP_WHITELIST=true
|
|||||||
# 啟用詳細日誌
|
# 啟用詳細日誌
|
||||||
# NEXT_PUBLIC_DEBUG=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範圍
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
# 注意事項
|
# 注意事項
|
||||||
# ================================
|
# ================================
|
||||||
|
198
lib/ip-utils.ts
198
lib/ip-utils.ts
@@ -54,30 +54,132 @@ export function isIpAllowed(clientIp: string, allowedIps: string): boolean {
|
|||||||
return false;
|
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
|
// 獲取客戶端真實IP
|
||||||
export function getClientIp(req: any): string {
|
export function getClientIp(req: any): string {
|
||||||
// 檢查各種可能的IP來源
|
// 按優先順序檢查各種IP來源
|
||||||
const ipSources = [
|
const ipSources = [
|
||||||
|
// 代理伺服器轉發的IP
|
||||||
req.headers['x-forwarded-for'],
|
req.headers['x-forwarded-for'],
|
||||||
req.headers['x-real-ip'],
|
req.headers['x-real-ip'],
|
||||||
req.headers['x-client-ip'],
|
req.headers['x-client-ip'],
|
||||||
req.headers['cf-connecting-ip'], // Cloudflare
|
req.headers['cf-connecting-ip'], // Cloudflare
|
||||||
|
req.headers['x-forwarded'], // 舊版代理頭
|
||||||
|
req.headers['forwarded-for'],
|
||||||
|
req.headers['forwarded'],
|
||||||
|
|
||||||
|
// 直接連接的IP
|
||||||
req.connection?.remoteAddress,
|
req.connection?.remoteAddress,
|
||||||
req.socket?.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) {
|
for (const ipSource of ipSources) {
|
||||||
if (ipSource) {
|
if (ipSource) {
|
||||||
// 處理多個IP的情況 (例如: "192.168.1.1, 10.0.0.1")
|
// 處理多個IP的情況 (例如: "192.168.1.1, 10.0.0.1, 203.0.113.1")
|
||||||
const firstIp = ipSource.toString().split(',')[0].trim();
|
const ipList = ipSource.toString().split(',').map(ip => ip.trim());
|
||||||
if (firstIp && firstIp !== '::1' && firstIp !== '127.0.0.1') {
|
|
||||||
return firstIp;
|
// 遍歷所有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地址格式
|
// 驗證IP地址格式
|
||||||
@@ -91,3 +193,85 @@ 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])$/;
|
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);
|
return cidrRegex.test(cidr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 獲取IP地理位置信息(可選功能)
|
||||||
|
export async function getIpLocation(ip: string): Promise<any> {
|
||||||
|
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<string, string | null>;
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
160
scripts/test-ip-detection.js
Normal file
160
scripts/test-ip-detection.js
Normal file
@@ -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
|
||||||
|
};
|
Reference in New Issue
Block a user