Merge branch 'main' into feat/workspace

This commit is contained in:
Wenjie Zhang 2026-05-04 10:51:41 +08:00 committed by GitHub
commit 7fbefc0963
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 318 additions and 14 deletions

View File

@ -5,6 +5,11 @@ SAVE_DIR=./saves
SILICONFLOW_API_KEY= # 推荐使用硅基流动免费服务 https://cloud.siliconflow.cn/i/Eo5yTHGJ
TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.com/
# JWT 安全配置:首次部署请生成随机值,初始化脚本会在留空时自动生成
YUXI_ENV=development
JWT_SECRET_KEY=
YUXI_INSTANCE_ID=
# # 其余可选配置
# OPENAI_API_KEY=
# OPENAI_API_BASE=

View File

@ -4,14 +4,14 @@ import re
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import User, APIKey
from server.utils.auth_utils import AuthUtils
from yuxi.storage.postgres.models_business import APIKey, User
from yuxi.utils.datetime_utils import utc_now_naive
from server.utils.auth_utils import AuthUtils
# 定义OAuth2密码承载器指定token URL
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
@ -115,10 +115,16 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
result = await db.execute(select(User).filter(User.id == int(user_id)))
result = await db.execute(select(User).filter(User.id == int(user_id), User.is_deleted == 0))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
if user.is_login_locked():
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="登录被锁定,请稍后重试",
headers={"X-Lock-Remaining": str(user.get_remaining_lock_time())},
)
return user

View File

@ -1,22 +1,52 @@
import hashlib
import hmac
import os
import secrets
from datetime import timedelta
from typing import Any
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
from yuxi.utils.datetime_utils import utc_now
# JWT配置
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "yuxi_know_secure_key")
LEGACY_JWT_SECRET_KEY = "yuxi_know_secure_key"
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION = 7 * 24 * 60 * 60 # 7天过期
JWT_AUDIENCE = "yuxi-know-api"
PASSWORD_HASHER = PasswordHasher()
def _is_production_env() -> bool:
return os.environ.get("YUXI_ENV", "development").strip().lower() in {"prod", "production"}
def _get_or_create_dev_env(name: str, value_factory) -> str:
value = os.environ.get(name, "").strip()
if value:
return value
if _is_production_env():
raise ValueError(f"{name} 未配置,请在生产环境的 .env.prod 中设置持久化随机值")
value = value_factory()
os.environ[name] = value
print(f"{name} 未配置,开发环境已自动生成临时随机值,服务重启后会重新生成。")
return value
def _get_jwt_secret_key() -> str:
secret_key = _get_or_create_dev_env("JWT_SECRET_KEY", lambda: secrets.token_hex(32))
if secret_key == LEGACY_JWT_SECRET_KEY:
raise ValueError("JWT_SECRET_KEY 不能使用历史默认密钥,请重新生成随机强密钥")
return secret_key
def _get_jwt_issuer() -> str:
instance_id = _get_or_create_dev_env("YUXI_INSTANCE_ID", lambda: f"instance-{secrets.token_hex(8)}")
return f"yuxi-know:{instance_id}"
class AuthUtils:
"""认证工具类"""
@ -53,26 +83,40 @@ class AuthUtils:
else:
expire = utc_now() + timedelta(seconds=JWT_EXPIRATION)
to_encode.update({"exp": expire})
to_encode.update({"exp": expire, "iss": _get_jwt_issuer(), "aud": JWT_AUDIENCE})
# 编码JWT
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
encoded_jwt = jwt.encode(to_encode, _get_jwt_secret_key(), algorithm=JWT_ALGORITHM)
return encoded_jwt
@staticmethod
def decode_token(token: str) -> dict[str, Any] | None:
"""解码验证JWT令牌"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
payload = jwt.decode(
token,
_get_jwt_secret_key(),
algorithms=[JWT_ALGORITHM],
issuer=_get_jwt_issuer(),
audience=JWT_AUDIENCE,
options={"require": ["exp", "sub", "iss", "aud"]},
)
return payload
except jwt.PyJWTError:
except (jwt.PyJWTError, ValueError):
return None
@staticmethod
def verify_access_token(token: str) -> dict[str, Any]:
"""验证访问令牌,如果无效则抛出异常"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
payload = jwt.decode(
token,
_get_jwt_secret_key(),
algorithms=[JWT_ALGORITHM],
issuer=_get_jwt_issuer(),
audience=JWT_AUDIENCE,
options={"require": ["exp", "sub", "iss", "aud"]},
)
return payload
except jwt.ExpiredSignatureError:
raise ValueError("令牌已过期")

View File

@ -25,7 +25,9 @@ async def test_user_is_locked_after_repeated_failed_logins(test_client, standard
assert response.status_code == 401, response.text
assert response.json()["detail"] == "用户名或密码错误"
locked_response = await test_client.post("/api/auth/token", data={"username": user_id, "password": "wrong-password"})
locked_response = await test_client.post(
"/api/auth/token", data={"username": user_id, "password": "wrong-password"}
)
assert locked_response.status_code == 423, locked_response.text
assert "X-Lock-Remaining" in locked_response.headers
assert "账户已被锁定" in locked_response.json()["detail"]
@ -73,3 +75,24 @@ async def test_invalid_token_is_rejected(test_client):
headers = {"Authorization": "Bearer not-a-real-token"}
response = await test_client.get("/api/auth/me", headers=headers)
assert response.status_code == 401
async def test_deleted_user_token_is_rejected(test_client, admin_headers, standard_user):
user_id = standard_user["user"]["id"]
delete_response = await test_client.delete(f"/api/auth/users/{user_id}", headers=admin_headers)
assert delete_response.status_code == 200, delete_response.text
profile_response = await test_client.get("/api/auth/me", headers=standard_user["headers"])
assert profile_response.status_code == 401
async def test_locked_user_token_is_rejected(test_client, standard_user):
user_id = standard_user["user"]["user_id"]
for _ in range(5):
await test_client.post("/api/auth/token", data={"username": user_id, "password": "wrong-password"})
profile_response = await test_client.get("/api/auth/me", headers=standard_user["headers"])
assert profile_response.status_code == 423
assert "X-Lock-Remaining" in profile_response.headers

View File

@ -1,8 +1,14 @@
from __future__ import annotations
import hashlib
import os
from datetime import timedelta
from server.utils.auth_utils import AuthUtils
import jwt
import pytest
from yuxi.utils.datetime_utils import utc_now
from server.utils.auth_utils import JWT_ALGORITHM, JWT_AUDIENCE, LEGACY_JWT_SECRET_KEY, AuthUtils
def test_hash_password_uses_argon2():
@ -18,3 +24,101 @@ def test_verify_password_accepts_legacy_sha256_format():
assert AuthUtils.verify_password(f"{legacy_hash}:salt", "secret-password") is True
assert AuthUtils.verify_password(f"{legacy_hash}:salt", "wrong-password") is False
def test_access_token_contains_instance_claims(monkeypatch):
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
token = AuthUtils.create_access_token({"sub": "1"})
payload = AuthUtils.verify_access_token(token)
assert payload["sub"] == "1"
assert payload["iss"] == "yuxi-know:pytest-instance"
assert payload["aud"] == JWT_AUDIENCE
def test_access_token_auto_generates_dev_secret(monkeypatch):
monkeypatch.setenv("YUXI_ENV", "development")
monkeypatch.delenv("JWT_SECRET_KEY", raising=False)
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
token = AuthUtils.create_access_token({"sub": "1"})
assert AuthUtils.verify_access_token(token)["sub"] == "1"
assert len(os.environ["JWT_SECRET_KEY"]) == 64
def test_access_token_requires_configured_secret_in_production(monkeypatch):
monkeypatch.setenv("YUXI_ENV", "production")
monkeypatch.delenv("JWT_SECRET_KEY", raising=False)
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
with pytest.raises(ValueError, match="JWT_SECRET_KEY"):
AuthUtils.create_access_token({"sub": "1"})
def test_access_token_requires_non_default_secret(monkeypatch):
monkeypatch.setenv("JWT_SECRET_KEY", LEGACY_JWT_SECRET_KEY)
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
with pytest.raises(ValueError, match="历史默认密钥"):
AuthUtils.create_access_token({"sub": "1"})
def test_access_token_auto_generates_dev_instance_id(monkeypatch):
monkeypatch.setenv("YUXI_ENV", "development")
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
monkeypatch.delenv("YUXI_INSTANCE_ID", raising=False)
token = AuthUtils.create_access_token({"sub": "1"})
assert AuthUtils.verify_access_token(token)["iss"].startswith("yuxi-know:instance-")
assert os.environ["YUXI_INSTANCE_ID"].startswith("instance-")
def test_access_token_requires_instance_id_in_production(monkeypatch):
monkeypatch.setenv("YUXI_ENV", "production")
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
monkeypatch.delenv("YUXI_INSTANCE_ID", raising=False)
with pytest.raises(ValueError, match="YUXI_INSTANCE_ID"):
AuthUtils.create_access_token({"sub": "1"})
def test_verify_access_token_rejects_wrong_issuer(monkeypatch):
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
token = jwt.encode(
{"sub": "1", "exp": utc_now() + timedelta(minutes=5), "iss": "yuxi-know:other", "aud": JWT_AUDIENCE},
"test-secret-key-with-enough-randomness",
algorithm=JWT_ALGORITHM,
)
assert AuthUtils.decode_token(token) is None
def test_verify_access_token_rejects_wrong_audience(monkeypatch):
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
token = jwt.encode(
{"sub": "1", "exp": utc_now() + timedelta(minutes=5), "iss": "yuxi-know:pytest-instance", "aud": "other-api"},
"test-secret-key-with-enough-randomness",
algorithm=JWT_ALGORITHM,
)
with pytest.raises(ValueError, match="无效的令牌"):
AuthUtils.verify_access_token(token)
def test_verify_access_token_requires_claims(monkeypatch):
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-with-enough-randomness")
monkeypatch.setenv("YUXI_INSTANCE_ID", "pytest-instance")
token = jwt.encode(
{"sub": "1", "exp": utc_now() + timedelta(minutes=5)},
"test-secret-key-with-enough-randomness",
algorithm=JWT_ALGORITHM,
)
with pytest.raises(ValueError, match="无效的令牌"):
AuthUtils.verify_access_token(token)

View File

@ -2,6 +2,7 @@ x-api-worker-env: &api-worker-env
HOST_IP: ${HOST_IP:-}
LITE_MODE: ${LITE_MODE:-}
RUNNING_IN_DOCKER: "true"
YUXI_ENV: production
MODEL_DIR_IN_DOCKER: /models
PYTHONPATH: /app/package
POSTGRES_URL: ${POSTGRES_URL:-postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-yuxi_know}}

View File

@ -2,6 +2,7 @@ x-api-worker-env: &api-worker-env
HOST_IP: ${HOST_IP:-}
LITE_MODE: ${LITE_MODE:-false}
RUNNING_IN_DOCKER: "true"
YUXI_ENV: development
MODEL_DIR_IN_DOCKER: /models
# DBs and other services
POSTGRES_URL: ${POSTGRES_URL:-postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-yuxi_know}}

View File

@ -38,6 +38,7 @@
<!-- 0.6.2 的内容请放在这里 -->
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API并增加“工作区”页面用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
- 加固 JWT 鉴权安全:移除历史默认密钥回退,初始化脚本支持生成并持久化 `JWT_SECRET_KEY``YUXI_INSTANCE_ID`,签发和验证令牌时校验 `iss/aud`,并在鉴权阶段拒绝已删除或登录锁定用户继续使用旧令牌访问系统。
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开避免空白区域误触发。

View File

@ -2,12 +2,49 @@
# This script helps set up the environment for the Yuxi project
# Note: API keys will be visible during input - use with care
function New-RandomHex($ByteCount) {
$bytes = [byte[]]::new($ByteCount)
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
return -join ($bytes | ForEach-Object { $_.ToString("x2") })
}
function Test-EnvValue($Name) {
return [bool](Select-String -Path ".env" -Pattern "^$Name=.+" -Quiet)
}
function Ensure-JwtEnv {
if ((Test-EnvValue "JWT_SECRET_KEY") -and (Test-EnvValue "YUXI_INSTANCE_ID")) {
return
}
Write-Host "JWT security settings are missing in .env." -ForegroundColor Yellow
$JWT_SECRET_KEY = Read-Host "Please enter your JWT_SECRET_KEY (press Enter to auto-generate)"
if ([string]::IsNullOrEmpty($JWT_SECRET_KEY)) {
$JWT_SECRET_KEY = New-RandomHex 32
Write-Host "Generated JWT_SECRET_KEY and saved it to .env." -ForegroundColor Green
}
$YUXI_INSTANCE_ID = Read-Host "Please enter your YUXI_INSTANCE_ID (press Enter to auto-generate)"
if ([string]::IsNullOrEmpty($YUXI_INSTANCE_ID)) {
$YUXI_INSTANCE_ID = "instance-$(New-RandomHex 8)"
Write-Host "Generated YUXI_INSTANCE_ID and saved it to .env." -ForegroundColor Green
}
@"
# JWT security settings
JWT_SECRET_KEY=$JWT_SECRET_KEY
YUXI_INSTANCE_ID=$YUXI_INSTANCE_ID
"@ | Add-Content -Path ".env" -Encoding UTF8
}
Write-Host "🚀 Initializing Yuxi project..." -ForegroundColor Cyan
Write-Host "==================================" -ForegroundColor Cyan
# Check if .env file exists
if (Test-Path ".env") {
Write-Host "✅ .env file already exists. Skipping environment setup." -ForegroundColor Green
Ensure-JwtEnv
} else {
Write-Host "📝 .env file not found. Let's set up your environment variables." -ForegroundColor Yellow
Write-Host ""
@ -32,6 +69,20 @@ if (Test-Path ".env") {
$TAVILY_API_KEY = Read-Host "Please enter your TAVILY_API_KEY (press Enter to skip)"
Write-Host ""
Write-Host "JWT security settings" -ForegroundColor Yellow
$JWT_SECRET_KEY = Read-Host "Please enter your JWT_SECRET_KEY (press Enter to auto-generate)"
if ([string]::IsNullOrEmpty($JWT_SECRET_KEY)) {
$JWT_SECRET_KEY = New-RandomHex 32
Write-Host "Generated JWT_SECRET_KEY and saved it to .env." -ForegroundColor Green
}
$YUXI_INSTANCE_ID = Read-Host "Please enter your YUXI_INSTANCE_ID (press Enter to auto-generate)"
if ([string]::IsNullOrEmpty($YUXI_INSTANCE_ID)) {
$YUXI_INSTANCE_ID = "instance-$(New-RandomHex 8)"
Write-Host "Generated YUXI_INSTANCE_ID and saved it to .env." -ForegroundColor Green
}
# Create .env file
$envContent = @"
# SiliconFlow API Key (required)
@ -44,12 +95,21 @@ SILICONFLOW_API_KEY=$apiKey
$envContent += "`nTAVILY_API_KEY=$TAVILY_API_KEY"
}
$envContent += @"
# JWT security settings
JWT_SECRET_KEY=$JWT_SECRET_KEY
YUXI_INSTANCE_ID=$YUXI_INSTANCE_ID
"@
$envContent | Out-File -FilePath ".env" -Encoding UTF8
Write-Host "✅ .env file created successfully!" -ForegroundColor Green
# Clear the variables from memory
Remove-Variable -Name "apiKey" -ErrorAction SilentlyContinue
Remove-Variable -Name "TAVILY_API_KEY" -ErrorAction SilentlyContinue
Remove-Variable -Name "JWT_SECRET_KEY" -ErrorAction SilentlyContinue
Remove-Variable -Name "YUXI_INSTANCE_ID" -ErrorAction SilentlyContinue
}
Write-Host ""

View File

@ -5,12 +5,49 @@
set -e
generate_hex() {
local length="$1"
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$length"
else
tr -dc 'a-f0-9' < /dev/urandom | head -c $((length * 2))
fi
}
ensure_jwt_env() {
if grep -Eq '^JWT_SECRET_KEY=.+' .env && grep -Eq '^YUXI_INSTANCE_ID=.+' .env; then
return
fi
echo "JWT security settings are missing in .env."
read -s -p "Please enter your JWT_SECRET_KEY (press Enter to auto-generate): " JWT_SECRET_KEY
echo ""
if [ -z "$JWT_SECRET_KEY" ]; then
JWT_SECRET_KEY=$(generate_hex 32)
echo "Generated JWT_SECRET_KEY and saved it to .env."
fi
read -p "Please enter your YUXI_INSTANCE_ID (press Enter to auto-generate): " YUXI_INSTANCE_ID
if [ -z "$YUXI_INSTANCE_ID" ]; then
YUXI_INSTANCE_ID="instance-$(generate_hex 8)"
echo "Generated YUXI_INSTANCE_ID and saved it to .env."
fi
cat >> .env << EOF
# JWT security settings
JWT_SECRET_KEY=${JWT_SECRET_KEY}
YUXI_INSTANCE_ID=${YUXI_INSTANCE_ID}
EOF
}
echo "🚀 Initializing Yuxi project..."
echo "=================================="
# Check if .env file exists
if [ -f ".env" ]; then
echo "✅ .env file already exists. Skipping environment setup."
ensure_jwt_env
else
echo "📝 .env file not found. Let's set up your environment variables."
echo ""
@ -34,6 +71,21 @@ else
echo "Get your API key from: https://app.tavily.com/"
read -p "Please enter your TAVILY_API_KEY (press Enter to skip): " TAVILY_API_KEY
echo ""
echo "JWT security settings"
read -s -p "Please enter your JWT_SECRET_KEY (press Enter to auto-generate): " JWT_SECRET_KEY
echo ""
if [ -z "$JWT_SECRET_KEY" ]; then
JWT_SECRET_KEY=$(generate_hex 32)
echo "Generated JWT_SECRET_KEY and saved it to .env."
fi
read -p "Please enter your YUXI_INSTANCE_ID (press Enter to auto-generate): " YUXI_INSTANCE_ID
if [ -z "$YUXI_INSTANCE_ID" ]; then
YUXI_INSTANCE_ID="instance-$(generate_hex 8)"
echo "Generated YUXI_INSTANCE_ID and saved it to .env."
fi
# Create .env file
cat > .env << EOF
# SiliconFlow API Key (required)
@ -46,6 +98,13 @@ EOF
echo "TAVILY_API_KEY=${TAVILY_API_KEY}" >> .env
fi
cat >> .env << EOF
# JWT security settings
JWT_SECRET_KEY=${JWT_SECRET_KEY}
YUXI_INSTANCE_ID=${YUXI_INSTANCE_ID}
EOF
echo "✅ .env file created successfully!"
fi