"frontend/src/vscode:/vscode.git/clone" did not exist on "064f9be7e4d23dfe1981c360f138521990692f38"
Unverified Commit 0a4641c2 authored by Edric.Li's avatar Edric.Li Committed by GitHub
Browse files

feat(api-key): 添加 IP 白名单/黑名单限制功能 (#221)

* feat(api-key): add IP whitelist/blacklist restriction and usage log IP tracking

- Add IP restriction feature for API keys (whitelist/blacklist with CIDR support)
- Add IP address logging to usage logs (admin-only visibility)
- Remove billing_type column from usage logs UI (redundant)
- Use generic "Access denied" error message for security

Backend:
- New ip package with IP/CIDR validation and matching utilities
- Database migrations for ip_whitelist, ip_blacklist (api_keys) and ip_address (usage_logs)
- Middleware IP restriction check after API key validation
- Input validation for IP/CIDR patterns on create/update

Frontend:
- API key form with enable toggle for IP restriction
- Shield icon indicator in table for keys with IP restriction
- Removed billing_type filter and column from usage views

* fix: update API contract tests for ip_whitelist/ip_blacklist fields

Add ip_whitelist and ip_blacklist fields to expected JSON responses
in API contract tests to match the new API key schema.
parent 62dc0b95
...@@ -367,6 +367,14 @@ export default { ...@@ -367,6 +367,14 @@ export default {
customKeyTooShort: '自定义密钥至少需要16个字符', customKeyTooShort: '自定义密钥至少需要16个字符',
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符', customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
customKeyRequired: '请输入自定义密钥', customKeyRequired: '请输入自定义密钥',
ipRestriction: 'IP 限制',
ipWhitelist: 'IP 白名单',
ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8',
ipWhitelistHint: '每行一个 IP 或 CIDR,设置后仅允许这些 IP 使用此密钥',
ipBlacklist: 'IP 黑名单',
ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16',
ipBlacklistHint: '每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥',
ipRestrictionEnabled: '已配置 IP 限制',
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。', ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。',
ccsClientSelect: { ccsClientSelect: {
title: '选择客户端', title: '选择客户端',
...@@ -427,9 +435,6 @@ export default { ...@@ -427,9 +435,6 @@ export default {
exportFailed: '使用数据导出失败', exportFailed: '使用数据导出失败',
exportExcelSuccess: '使用数据导出成功(Excel格式)', exportExcelSuccess: '使用数据导出成功(Excel格式)',
exportExcelFailed: '使用数据导出失败', exportExcelFailed: '使用数据导出失败',
billingType: '消费类型',
balance: '余额',
subscription: '订阅',
imageUnit: '', imageUnit: '',
userAgent: 'User-Agent' userAgent: 'User-Agent'
}, },
...@@ -1880,7 +1885,6 @@ export default { ...@@ -1880,7 +1885,6 @@ export default {
allAccounts: '全部账户', allAccounts: '全部账户',
allGroups: '全部分组', allGroups: '全部分组',
allTypes: '全部类型', allTypes: '全部类型',
allBillingTypes: '全部计费',
inputCost: '输入成本', inputCost: '输入成本',
outputCost: '输出成本', outputCost: '输出成本',
cacheCreationCost: '缓存创建成本', cacheCreationCost: '缓存创建成本',
...@@ -1889,7 +1893,8 @@ export default { ...@@ -1889,7 +1893,8 @@ export default {
outputTokens: '输出 Token', outputTokens: '输出 Token',
cacheCreationTokens: '缓存创建 Token', cacheCreationTokens: '缓存创建 Token',
cacheReadTokens: '缓存读取 Token', cacheReadTokens: '缓存读取 Token',
failedToLoad: '加载使用记录失败' failedToLoad: '加载使用记录失败',
ipAddress: 'IP'
}, },
// Settings // Settings
......
...@@ -279,6 +279,8 @@ export interface ApiKey { ...@@ -279,6 +279,8 @@ export interface ApiKey {
name: string name: string
group_id: number | null group_id: number | null
status: 'active' | 'inactive' status: 'active' | 'inactive'
ip_whitelist: string[]
ip_blacklist: string[]
created_at: string created_at: string
updated_at: string updated_at: string
group?: Group group?: Group
...@@ -288,12 +290,16 @@ export interface CreateApiKeyRequest { ...@@ -288,12 +290,16 @@ export interface CreateApiKeyRequest {
name: string name: string
group_id?: number | null group_id?: number | null
custom_key?: string // Optional custom API Key custom_key?: string // Optional custom API Key
ip_whitelist?: string[]
ip_blacklist?: string[]
} }
export interface UpdateApiKeyRequest { export interface UpdateApiKeyRequest {
name?: string name?: string
group_id?: number | null group_id?: number | null
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
ip_whitelist?: string[]
ip_blacklist?: string[]
} }
export interface CreateGroupRequest { export interface CreateGroupRequest {
...@@ -560,9 +566,6 @@ export interface UpdateProxyRequest { ...@@ -560,9 +566,6 @@ export interface UpdateProxyRequest {
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription'
// 消费类型: 0=钱包余额, 1=订阅套餐
export type BillingType = 0 | 1
export interface UsageLog { export interface UsageLog {
id: number id: number
user_id: number user_id: number
...@@ -589,7 +592,6 @@ export interface UsageLog { ...@@ -589,7 +592,6 @@ export interface UsageLog {
actual_cost: number actual_cost: number
rate_multiplier: number rate_multiplier: number
billing_type: BillingType
stream: boolean stream: boolean
duration_ms: number duration_ms: number
first_token_ms: number | null first_token_ms: number | null
...@@ -601,6 +603,9 @@ export interface UsageLog { ...@@ -601,6 +603,9 @@ export interface UsageLog {
// User-Agent // User-Agent
user_agent: string | null user_agent: string | null
// IP 地址(仅管理员可见)
ip_address: string | null
created_at: string created_at: string
user?: User user?: User
...@@ -830,7 +835,6 @@ export interface UsageQueryParams { ...@@ -830,7 +835,6 @@ export interface UsageQueryParams {
group_id?: number group_id?: number
model?: string model?: string
stream?: boolean stream?: boolean
billing_type?: number
start_date?: string start_date?: string
end_date?: string end_date?: string
} }
......
...@@ -95,8 +95,8 @@ const exportToExcel = async () => { ...@@ -95,8 +95,8 @@ const exportToExcel = async () => {
t('admin.usage.inputCost'), t('admin.usage.outputCost'), t('admin.usage.inputCost'), t('admin.usage.outputCost'),
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'), t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'), t('usage.rate'), t('usage.original'), t('usage.billed'),
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'), t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId'), t('usage.userAgent') t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
] ]
const rows = all.map(log => [ const rows = all.map(log => [
log.created_at, log.created_at,
...@@ -117,11 +117,11 @@ const exportToExcel = async () => { ...@@ -117,11 +117,11 @@ const exportToExcel = async () => {
log.rate_multiplier?.toFixed(2) || '1.00', log.rate_multiplier?.toFixed(2) || '1.00',
log.total_cost?.toFixed(6) || '0.000000', log.total_cost?.toFixed(6) || '0.000000',
log.actual_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'),
log.first_token_ms ?? '', log.first_token_ms ?? '',
log.duration_ms, log.duration_ms,
log.request_id || '', log.request_id || '',
log.user_agent || '' log.user_agent || '',
log.ip_address || ''
]) ])
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]) const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
......
...@@ -46,8 +46,17 @@ ...@@ -46,8 +46,17 @@
</div> </div>
</template> </template>
<template #cell-name="{ value }"> <template #cell-name="{ value, row }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <div class="flex items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<Icon
v-if="row.ip_whitelist?.length > 0 || row.ip_blacklist?.length > 0"
name="shield"
size="sm"
class="text-blue-500"
:title="t('keys.ipRestrictionEnabled')"
/>
</div>
</template> </template>
<template #cell-group="{ row }"> <template #cell-group="{ row }">
...@@ -278,6 +287,52 @@ ...@@ -278,6 +287,52 @@
:placeholder="t('keys.selectStatus')" :placeholder="t('keys.selectStatus')"
/> />
</div> </div>
<!-- IP Restriction Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.ipRestriction') }}</label>
<button
type="button"
@click="formData.enable_ip_restriction = !formData.enable_ip_restriction"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_ip_restriction ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_ip_restriction ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="formData.enable_ip_restriction" class="space-y-4 pt-2">
<div>
<label class="input-label">{{ t('keys.ipWhitelist') }}</label>
<textarea
v-model="formData.ip_whitelist"
rows="3"
class="input font-mono text-sm"
:placeholder="t('keys.ipWhitelistPlaceholder')"
/>
<p class="input-hint">{{ t('keys.ipWhitelistHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('keys.ipBlacklist') }}</label>
<textarea
v-model="formData.ip_blacklist"
rows="3"
class="input font-mono text-sm"
:placeholder="t('keys.ipBlacklistPlaceholder')"
/>
<p class="input-hint">{{ t('keys.ipBlacklistHint') }}</p>
</div>
</div>
</div>
</form> </form>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
...@@ -528,7 +583,10 @@ const formData = ref({ ...@@ -528,7 +583,10 @@ const formData = ref({
group_id: null as number | null, group_id: null as number | null,
status: 'active' as 'active' | 'inactive', status: 'active' as 'active' | 'inactive',
use_custom_key: false, use_custom_key: false,
custom_key: '' custom_key: '',
enable_ip_restriction: false,
ip_whitelist: '',
ip_blacklist: ''
}) })
// 自定义Key验证 // 自定义Key验证
...@@ -664,12 +722,16 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -664,12 +722,16 @@ const handlePageSizeChange = (pageSize: number) => {
const editKey = (key: ApiKey) => { const editKey = (key: ApiKey) => {
selectedKey.value = key selectedKey.value = key
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
formData.value = { formData.value = {
name: key.name, name: key.name,
group_id: key.group_id, group_id: key.group_id,
status: key.status, status: key.status,
use_custom_key: false, use_custom_key: false,
custom_key: '' custom_key: '',
enable_ip_restriction: hasIPRestriction,
ip_whitelist: (key.ip_whitelist || []).join('\n'),
ip_blacklist: (key.ip_blacklist || []).join('\n')
} }
showEditModal.value = true showEditModal.value = true
} }
...@@ -751,14 +813,26 @@ const handleSubmit = async () => { ...@@ -751,14 +813,26 @@ const handleSubmit = async () => {
} }
} }
// Parse IP lists only if IP restriction is enabled
const parseIPList = (text: string): string[] =>
text.split('\n').map(ip => ip.trim()).filter(ip => ip.length > 0)
const ipWhitelist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_whitelist) : []
const ipBlacklist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_blacklist) : []
submitting.value = true submitting.value = true
try { try {
if (showEditModal.value && selectedKey.value) { if (showEditModal.value && selectedKey.value) {
await keysAPI.update(selectedKey.value.id, formData.value) await keysAPI.update(selectedKey.value.id, {
name: formData.value.name,
group_id: formData.value.group_id,
status: formData.value.status,
ip_whitelist: ipWhitelist,
ip_blacklist: ipBlacklist
})
appStore.showSuccess(t('keys.keyUpdatedSuccess')) appStore.showSuccess(t('keys.keyUpdatedSuccess'))
} else { } else {
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
await keysAPI.create(formData.value.name, formData.value.group_id, customKey) await keysAPI.create(formData.value.name, formData.value.group_id, customKey, ipWhitelist, ipBlacklist)
appStore.showSuccess(t('keys.keyCreatedSuccess')) appStore.showSuccess(t('keys.keyCreatedSuccess'))
// Only advance tour if active, on submit step, and creation succeeded // Only advance tour if active, on submit step, and creation succeeded
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) { if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
...@@ -805,7 +879,10 @@ const closeModals = () => { ...@@ -805,7 +879,10 @@ const closeModals = () => {
group_id: null, group_id: null,
status: 'active', status: 'active',
use_custom_key: false, use_custom_key: false,
custom_key: '' custom_key: '',
enable_ip_restriction: false,
ip_whitelist: '',
ip_blacklist: ''
} }
} }
......
...@@ -273,19 +273,6 @@ ...@@ -273,19 +273,6 @@
</div> </div>
</template> </template>
<template #cell-billing_type="{ row }">
<span
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:class="
row.billing_type === 1
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
"
>
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
</span>
</template>
<template #cell-first_token="{ row }"> <template #cell-first_token="{ row }">
<span <span
v-if="row.first_token_ms != null" v-if="row.first_token_ms != null"
...@@ -482,7 +469,6 @@ const columns = computed<Column[]>(() => [ ...@@ -482,7 +469,6 @@ const columns = computed<Column[]>(() => [
{ key: 'stream', label: t('usage.type'), sortable: false }, { key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false }, { key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false }, { key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false }, { key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true }, { key: 'created_at', label: t('usage.time'), sortable: true },
...@@ -745,7 +731,6 @@ const exportToCSV = async () => { ...@@ -745,7 +731,6 @@ const exportToCSV = async () => {
'Rate Multiplier', 'Rate Multiplier',
'Billed Cost', 'Billed Cost',
'Original Cost', 'Original Cost',
'Billing Type',
'First Token (ms)', 'First Token (ms)',
'Duration (ms)' 'Duration (ms)'
] ]
...@@ -762,7 +747,6 @@ const exportToCSV = async () => { ...@@ -762,7 +747,6 @@ const exportToCSV = async () => {
log.rate_multiplier, log.rate_multiplier,
log.actual_cost.toFixed(8), log.actual_cost.toFixed(8),
log.total_cost.toFixed(8), log.total_cost.toFixed(8),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.first_token_ms ?? '', log.first_token_ms ?? '',
log.duration_ms log.duration_ms
].map(escapeCSVValue) ].map(escapeCSVValue)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment