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.
This commit is contained in:
@@ -64,7 +64,6 @@ export async function getStats(params: {
|
||||
group_id?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
billing_type?: number
|
||||
period?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
|
||||
@@ -42,12 +42,16 @@ export async function getById(id: number): Promise<ApiKey> {
|
||||
* @param name - Key name
|
||||
* @param groupId - Optional group ID
|
||||
* @param customKey - Optional custom key value
|
||||
* @param ipWhitelist - Optional IP whitelist
|
||||
* @param ipBlacklist - Optional IP blacklist
|
||||
* @returns Created API key
|
||||
*/
|
||||
export async function create(
|
||||
name: string,
|
||||
groupId?: number | null,
|
||||
customKey?: string
|
||||
customKey?: string,
|
||||
ipWhitelist?: string[],
|
||||
ipBlacklist?: string[]
|
||||
): Promise<ApiKey> {
|
||||
const payload: CreateApiKeyRequest = { name }
|
||||
if (groupId !== undefined) {
|
||||
@@ -56,6 +60,12 @@ export async function create(
|
||||
if (customKey) {
|
||||
payload.custom_key = customKey
|
||||
}
|
||||
if (ipWhitelist && ipWhitelist.length > 0) {
|
||||
payload.ip_whitelist = ipWhitelist
|
||||
}
|
||||
if (ipBlacklist && ipBlacklist.length > 0) {
|
||||
payload.ip_blacklist = ipBlacklist
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<ApiKey>('/keys', payload)
|
||||
return data
|
||||
|
||||
@@ -127,12 +127,6 @@
|
||||
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.billingType') }}</label>
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Group Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||
@@ -227,12 +221,6 @@ const streamTypeOptions = ref<SelectOption[]>([
|
||||
{ value: false, label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||
{ value: 1, label: t('usage.subscription') },
|
||||
{ value: 0, label: t('usage.balance') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
|
||||
const updateStartDate = (value: string) => {
|
||||
|
||||
@@ -96,12 +96,6 @@
|
||||
</div>
|
||||
</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 }">
|
||||
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
@@ -120,6 +114,11 @@
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-ip_address="{ row }">
|
||||
<span v-if="row.ip_address" class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
@@ -249,11 +248,11 @@ const cols = computed(() => [
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), 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: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false }
|
||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
|
||||
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
|
||||
])
|
||||
|
||||
const formatCacheTokens = (tokens: number): string => {
|
||||
|
||||
@@ -370,6 +370,14 @@ export default {
|
||||
customKeyTooShort: 'Custom key must be at least 16 characters',
|
||||
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
|
||||
customKeyRequired: 'Please enter a custom key',
|
||||
ipRestriction: 'IP Restriction',
|
||||
ipWhitelist: 'IP Whitelist',
|
||||
ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8',
|
||||
ipWhitelistHint: 'One IP or CIDR per line. Only these IPs can use this key when set.',
|
||||
ipBlacklist: 'IP Blacklist',
|
||||
ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16',
|
||||
ipBlacklistHint: 'One IP or CIDR per line. These IPs will be blocked from using this key.',
|
||||
ipRestrictionEnabled: 'IP restriction enabled',
|
||||
ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.',
|
||||
ccsClientSelect: {
|
||||
title: 'Select Client',
|
||||
@@ -430,9 +438,6 @@ export default {
|
||||
exportFailed: 'Failed to export usage data',
|
||||
exportExcelSuccess: 'Usage data exported successfully (Excel format)',
|
||||
exportExcelFailed: 'Failed to export usage data',
|
||||
billingType: 'Billing',
|
||||
balance: 'Balance',
|
||||
subscription: 'Subscription',
|
||||
imageUnit: ' images',
|
||||
userAgent: 'User-Agent'
|
||||
},
|
||||
@@ -1735,7 +1740,6 @@ export default {
|
||||
allAccounts: 'All Accounts',
|
||||
allGroups: 'All Groups',
|
||||
allTypes: 'All Types',
|
||||
allBillingTypes: 'All Billing',
|
||||
inputCost: 'Input Cost',
|
||||
outputCost: 'Output Cost',
|
||||
cacheCreationCost: 'Cache Creation Cost',
|
||||
@@ -1744,7 +1748,8 @@ export default {
|
||||
outputTokens: 'Output Tokens',
|
||||
cacheCreationTokens: 'Cache Creation Tokens',
|
||||
cacheReadTokens: 'Cache Read Tokens',
|
||||
failedToLoad: 'Failed to load usage records'
|
||||
failedToLoad: 'Failed to load usage records',
|
||||
ipAddress: 'IP'
|
||||
},
|
||||
|
||||
// Settings
|
||||
|
||||
@@ -367,6 +367,14 @@ export default {
|
||||
customKeyTooShort: '自定义密钥至少需要16个字符',
|
||||
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
|
||||
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 密钥。',
|
||||
ccsClientSelect: {
|
||||
title: '选择客户端',
|
||||
@@ -427,9 +435,6 @@ export default {
|
||||
exportFailed: '使用数据导出失败',
|
||||
exportExcelSuccess: '使用数据导出成功(Excel格式)',
|
||||
exportExcelFailed: '使用数据导出失败',
|
||||
billingType: '消费类型',
|
||||
balance: '余额',
|
||||
subscription: '订阅',
|
||||
imageUnit: '张',
|
||||
userAgent: 'User-Agent'
|
||||
},
|
||||
@@ -1880,7 +1885,6 @@ export default {
|
||||
allAccounts: '全部账户',
|
||||
allGroups: '全部分组',
|
||||
allTypes: '全部类型',
|
||||
allBillingTypes: '全部计费',
|
||||
inputCost: '输入成本',
|
||||
outputCost: '输出成本',
|
||||
cacheCreationCost: '缓存创建成本',
|
||||
@@ -1889,7 +1893,8 @@ export default {
|
||||
outputTokens: '输出 Token',
|
||||
cacheCreationTokens: '缓存创建 Token',
|
||||
cacheReadTokens: '缓存读取 Token',
|
||||
failedToLoad: '加载使用记录失败'
|
||||
failedToLoad: '加载使用记录失败',
|
||||
ipAddress: 'IP'
|
||||
},
|
||||
|
||||
// Settings
|
||||
|
||||
@@ -279,6 +279,8 @@ export interface ApiKey {
|
||||
name: string
|
||||
group_id: number | null
|
||||
status: 'active' | 'inactive'
|
||||
ip_whitelist: string[]
|
||||
ip_blacklist: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
group?: Group
|
||||
@@ -288,12 +290,16 @@ export interface CreateApiKeyRequest {
|
||||
name: string
|
||||
group_id?: number | null
|
||||
custom_key?: string // Optional custom API Key
|
||||
ip_whitelist?: string[]
|
||||
ip_blacklist?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateApiKeyRequest {
|
||||
name?: string
|
||||
group_id?: number | null
|
||||
status?: 'active' | 'inactive'
|
||||
ip_whitelist?: string[]
|
||||
ip_blacklist?: string[]
|
||||
}
|
||||
|
||||
export interface CreateGroupRequest {
|
||||
@@ -560,9 +566,6 @@ export interface UpdateProxyRequest {
|
||||
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription'
|
||||
|
||||
// 消费类型: 0=钱包余额, 1=订阅套餐
|
||||
export type BillingType = 0 | 1
|
||||
|
||||
export interface UsageLog {
|
||||
id: number
|
||||
user_id: number
|
||||
@@ -589,7 +592,6 @@ export interface UsageLog {
|
||||
actual_cost: number
|
||||
rate_multiplier: number
|
||||
|
||||
billing_type: BillingType
|
||||
stream: boolean
|
||||
duration_ms: number
|
||||
first_token_ms: number | null
|
||||
@@ -601,6 +603,9 @@ export interface UsageLog {
|
||||
// User-Agent
|
||||
user_agent: string | null
|
||||
|
||||
// IP 地址(仅管理员可见)
|
||||
ip_address: string | null
|
||||
|
||||
created_at: string
|
||||
|
||||
user?: User
|
||||
@@ -830,7 +835,6 @@ export interface UsageQueryParams {
|
||||
group_id?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
billing_type?: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
@@ -95,8 +95,8 @@ const exportToExcel = async () => {
|
||||
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
|
||||
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
|
||||
t('usage.rate'), t('usage.original'), t('usage.billed'),
|
||||
t('usage.billingType'), t('usage.firstToken'), t('usage.duration'),
|
||||
t('admin.usage.requestId'), t('usage.userAgent')
|
||||
t('usage.firstToken'), t('usage.duration'),
|
||||
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
|
||||
]
|
||||
const rows = all.map(log => [
|
||||
log.created_at,
|
||||
@@ -117,11 +117,11 @@ const exportToExcel = async () => {
|
||||
log.rate_multiplier?.toFixed(2) || '1.00',
|
||||
log.total_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.duration_ms,
|
||||
log.request_id || '',
|
||||
log.user_agent || ''
|
||||
log.user_agent || '',
|
||||
log.ip_address || ''
|
||||
])
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
|
||||
const wb = XLSX.utils.book_new()
|
||||
|
||||
@@ -46,8 +46,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<template #cell-name="{ value, row }">
|
||||
<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 #cell-group="{ row }">
|
||||
@@ -278,6 +287,52 @@
|
||||
:placeholder="t('keys.selectStatus')"
|
||||
/>
|
||||
</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>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
@@ -528,7 +583,10 @@ const formData = ref({
|
||||
group_id: null as number | null,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
use_custom_key: false,
|
||||
custom_key: ''
|
||||
custom_key: '',
|
||||
enable_ip_restriction: false,
|
||||
ip_whitelist: '',
|
||||
ip_blacklist: ''
|
||||
})
|
||||
|
||||
// 自定义Key验证
|
||||
@@ -664,12 +722,16 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
|
||||
const editKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
|
||||
formData.value = {
|
||||
name: key.name,
|
||||
group_id: key.group_id,
|
||||
status: key.status,
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
try {
|
||||
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'))
|
||||
} else {
|
||||
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'))
|
||||
// Only advance tour if active, on submit step, and creation succeeded
|
||||
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
|
||||
@@ -805,7 +879,10 @@ const closeModals = () => {
|
||||
group_id: null,
|
||||
status: 'active',
|
||||
use_custom_key: false,
|
||||
custom_key: ''
|
||||
custom_key: '',
|
||||
enable_ip_restriction: false,
|
||||
ip_whitelist: '',
|
||||
ip_blacklist: ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,19 +273,6 @@
|
||||
</div>
|
||||
</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 }">
|
||||
<span
|
||||
v-if="row.first_token_ms != null"
|
||||
@@ -482,7 +469,6 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), 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: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
@@ -745,7 +731,6 @@ const exportToCSV = async () => {
|
||||
'Rate Multiplier',
|
||||
'Billed Cost',
|
||||
'Original Cost',
|
||||
'Billing Type',
|
||||
'First Token (ms)',
|
||||
'Duration (ms)'
|
||||
]
|
||||
@@ -762,7 +747,6 @@ const exportToCSV = async () => {
|
||||
log.rate_multiplier,
|
||||
log.actual_cost.toFixed(8),
|
||||
log.total_cost.toFixed(8),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.first_token_ms ?? '',
|
||||
log.duration_ms
|
||||
].map(escapeCSVValue)
|
||||
|
||||
Reference in New Issue
Block a user