fix(security): Tailscale内网(100.64/10)免API_KEY+外部强制校验;修复账号重复提交400(提交锁+唯一约束兜底)
This commit is contained in:
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
APP_NAME=VPS 资产管理系统
|
APP_NAME=VPS 资产管理系统
|
||||||
|
|
||||||
# 写操作(POST/PUT/DELETE)鉴权密钥
|
# 写操作(POST/PUT/DELETE)鉴权密钥
|
||||||
# 留空 = 不校验(适用于纯 Tailscale 内网环境)
|
# Tailscale 内网(100.64.0.0/10,如 100.89.x.x)与本机来源自动放行,无需携带;
|
||||||
# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥>
|
# 外部来源必须携带 X-API-Key: <此密钥>;留空则外部来源一律拒绝(仅内网可用)
|
||||||
API_KEY=
|
API_KEY=
|
||||||
|
|
||||||
# Agent 上报鉴权密钥(可选)
|
# Agent 上报鉴权密钥(可选)
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,8 @@
|
|||||||
"""应用配置
|
"""应用配置
|
||||||
|
|
||||||
从项目根目录的 .env 文件加载配置(若存在)。
|
从项目根目录的 .env 文件加载配置(若存在)。
|
||||||
API_KEY 留空表示不启用写操作鉴权(适用于纯内网环境)。
|
API_KEY 用于保护写操作:Tailscale 内网/本机来源放行,外部来源必须携带正确密钥;
|
||||||
|
留空则外部来源一律拒绝(仅内网可用)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -17,7 +18,8 @@ class Settings:
|
|||||||
"""应用配置项"""
|
"""应用配置项"""
|
||||||
|
|
||||||
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
|
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
|
||||||
# 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验
|
# 写操作(POST/PUT/DELETE)鉴权密钥;Tailscale 内网/本机来源忽略校验,
|
||||||
|
# 外部来源必须携带 X-API-Key(留空则外部来源直接拒绝)
|
||||||
API_KEY: str = os.getenv("API_KEY", "")
|
API_KEY: str = os.getenv("API_KEY", "")
|
||||||
# Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
|
# Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
|
||||||
AGENT_KEY: str = os.getenv("AGENT_KEY", "")
|
AGENT_KEY: str = os.getenv("AGENT_KEY", "")
|
||||||
|
|||||||
+46
-5
@@ -1,17 +1,26 @@
|
|||||||
"""API Key 认证依赖
|
"""API Key 认证依赖
|
||||||
|
|
||||||
用于保护写操作(POST/PUT/DELETE)。
|
用于保护写操作(POST/PUT/DELETE)。
|
||||||
- 若 settings.API_KEY 为空,则放行所有请求(纯内网场景)。
|
- 来自可信内网(Tailscale 100.64.0.0/10、本机回环)的请求直接放行;
|
||||||
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401。
|
- 其余来源:API_KEY 已配置则校验 X-API-Key,未配置则拒绝(防止外部裸奔)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
|
import ipaddress
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, status
|
from fastapi import Header, HTTPException, Request, status
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# 可信来源网段:Tailscale 使用 CGNAT 100.64.0.0/10 分配内网 IP(如 100.89.x.x);
|
||||||
|
# 回环地址覆盖 tailscale serve 代理转发与本地开发场景。
|
||||||
|
TRUSTED_NETWORKS = [
|
||||||
|
ipaddress.ip_network("100.64.0.0/10"),
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("::1/128"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _key_matches(provided: Optional[str], expected: str) -> bool:
|
def _key_matches(provided: Optional[str], expected: str) -> bool:
|
||||||
"""常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息"""
|
"""常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息"""
|
||||||
@@ -20,12 +29,44 @@ def _key_matches(provided: Optional[str], expected: str) -> bool:
|
|||||||
return hmac.compare_digest(provided.encode(), expected.encode())
|
return hmac.compare_digest(provided.encode(), expected.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""获取客户端真实 IP
|
||||||
|
|
||||||
|
tailscale serve 转发到本机时 request.client 为回环地址,真实来源在
|
||||||
|
X-Forwarded-For 头中;仅信任来自回环的 XFF,防止外部直连时伪造
|
||||||
|
X-Forwarded-For 绕过鉴权。
|
||||||
|
"""
|
||||||
|
host = request.client.host if request.client else ""
|
||||||
|
if host in ("127.0.0.1", "::1"):
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
return xff.split(",")[0].strip()
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted(ip: str) -> bool:
|
||||||
|
"""判断来源 IP 是否属于可信内网(Tailscale 网段 / 本机回环)"""
|
||||||
|
if not ip:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(addr in net for net in TRUSTED_NETWORKS)
|
||||||
|
|
||||||
|
|
||||||
async def require_api_key(
|
async def require_api_key(
|
||||||
|
request: Request,
|
||||||
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""校验 API Key(可选启用)"""
|
"""校验 API Key:Tailscale 内网/本机放行,外部来源必须携带有效 Key"""
|
||||||
if not settings.API_KEY:
|
if _is_trusted(_client_ip(request)):
|
||||||
return
|
return
|
||||||
|
if not settings.API_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="外部访问需要 API Key,请先在 .env 配置 API_KEY 并设置到前端",
|
||||||
|
)
|
||||||
if not _key_matches(x_api_key, settings.API_KEY):
|
if not _key_matches(x_api_key, settings.API_KEY):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Dict, List
|
|||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core import crypto
|
from app.core import crypto
|
||||||
@@ -53,8 +54,15 @@ def _norm_platform(platform: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None:
|
def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None:
|
||||||
"""校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用"""
|
"""校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用
|
||||||
stmt = select(Account).where(Account.name == name, Account.platform == platform)
|
|
||||||
|
platform 用 coalesce 归一化匹配:历史数据的 NULL 与空串视为同一平台,
|
||||||
|
避免出现同名账号在不同"空平台"上各存一条。
|
||||||
|
"""
|
||||||
|
stmt = select(Account).where(
|
||||||
|
Account.name == name,
|
||||||
|
func.coalesce(Account.platform, "") == platform,
|
||||||
|
)
|
||||||
if exclude_id is not None:
|
if exclude_id is not None:
|
||||||
stmt = stmt.where(Account.id != exclude_id)
|
stmt = stmt.where(Account.id != exclude_id)
|
||||||
if session.exec(stmt).first():
|
if session.exec(stmt).first():
|
||||||
@@ -73,8 +81,16 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
|||||||
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
|
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
|
||||||
account.login_password_encrypted = crypto.encrypt(data.login_password)
|
account.login_password_encrypted = crypto.encrypt(data.login_password)
|
||||||
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
||||||
session.add(account)
|
try:
|
||||||
session.commit()
|
session.add(account)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"该平台下账号已存在:{name}",
|
||||||
|
)
|
||||||
session.refresh(account)
|
session.refresh(account)
|
||||||
return _to_read(account, _asset_counts(session))
|
return _to_read(account, _asset_counts(session))
|
||||||
|
|
||||||
@@ -101,8 +117,16 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
|
|||||||
account.login_password_encrypted = crypto.encrypt(data.login_password)
|
account.login_password_encrypted = crypto.encrypt(data.login_password)
|
||||||
if data.api_config is not None:
|
if data.api_config is not None:
|
||||||
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
||||||
session.add(account)
|
try:
|
||||||
session.commit()
|
session.add(account)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# 重命名撞上已有账号时唯一约束兜底,与 _check_name_taken 存在竞态窗口
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"该平台下账号已存在:{account.name}",
|
||||||
|
)
|
||||||
session.refresh(account)
|
session.refresh(account)
|
||||||
return _to_read(account, _asset_counts(session))
|
return _to_read(account, _asset_counts(session))
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -231,7 +231,7 @@ const AccountModal = {
|
|||||||
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
|
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" @click="store.accountModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
<button type="button" @click="store.accountModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||||
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button>
|
<button type="submit" :disabled="store.accountSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.accountSaving ? '保存中…' : '保存' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const store = reactive({
|
|||||||
assetModal: { show: false, editing: null, form: null },
|
assetModal: { show: false, editing: null, form: null },
|
||||||
providerModal: { show: false, editing: null, form: null },
|
providerModal: { show: false, editing: null, form: null },
|
||||||
accountModal: { show: false, editing: null, form: null },
|
accountModal: { show: false, editing: null, form: null },
|
||||||
|
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
|
||||||
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
|
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,7 +182,10 @@ function openAccountEdit(a) {
|
|||||||
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
|
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
|
||||||
}
|
}
|
||||||
async function saveAccount() {
|
async function saveAccount() {
|
||||||
|
// 提交锁:双击/网络慢时重复点击会发出两次请求,第二次必然重名 400
|
||||||
|
if (store.accountSaving) return;
|
||||||
store.error = '';
|
store.error = '';
|
||||||
|
store.accountSaving = true;
|
||||||
const f = store.accountModal.form;
|
const f = store.accountModal.form;
|
||||||
const payload = {
|
const payload = {
|
||||||
name: f.name, platform: f.platform || null, remark: f.remark || null,
|
name: f.name, platform: f.platform || null, remark: f.remark || null,
|
||||||
@@ -196,6 +200,7 @@ async function saveAccount() {
|
|||||||
// 重命名会同步更新资产端引用,需一并刷新资产
|
// 重命名会同步更新资产端引用,需一并刷新资产
|
||||||
await Promise.all([loadAccounts(), loadAssets()]);
|
await Promise.all([loadAccounts(), loadAssets()]);
|
||||||
} catch (e) { store.error = e.message; }
|
} catch (e) { store.error = e.message; }
|
||||||
|
finally { store.accountSaving = false; }
|
||||||
}
|
}
|
||||||
async function deleteAccount(a) {
|
async function deleteAccount(a) {
|
||||||
let msg = '确认删除账号「' + a.name + '」?';
|
let msg = '确认删除账号「' + a.name + '」?';
|
||||||
|
|||||||
Reference in New Issue
Block a user