From 81f97d96fd29d082cd383d9445b9f05d514755d4 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Fri, 31 Jul 2026 20:22:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96jwt=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-fastapi-backend/.env.dev | 4 +- .../controller/login_controller.py | 6 +-- .../module_admin/service/login_service.py | 14 +++--- .../module_admin/service/online_service.py | 12 +++-- .../service/test_login_service.py | 22 ++++++++- .../service/test_online_service.py | 25 ++++++++++ .../tests/utils/test_jwt_util.py | 29 +++++++++++ ruoyi-fastapi-backend/utils/jwt_util.py | 48 +++++++++++++++++++ 8 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_online_service.py create mode 100644 ruoyi-fastapi-backend/tests/utils/test_jwt_util.py create mode 100644 ruoyi-fastapi-backend/utils/jwt_util.py diff --git a/ruoyi-fastapi-backend/.env.dev b/ruoyi-fastapi-backend/.env.dev index e79dbbf..8c2b785 100644 --- a/ruoyi-fastapi-backend/.env.dev +++ b/ruoyi-fastapi-backend/.env.dev @@ -35,8 +35,8 @@ APP_TRUSTED_PROXY_HOPS = 1 APP_DEFAULT_ENABLED_PLUGINS = 'ai' # -------- Jwt配置 -------- -# Jwt秘钥,留空时自动生成;也可使用 openssl rand -hex 32 手动生成 -JWT_SECRET_KEY = '' +# Jwt秘钥,留空时自动生成,开发环境下不要留空;也可使用 openssl rand -hex 32 手动生成 +JWT_SECRET_KEY = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55' # Jwt算法 JWT_ALGORITHM = 'HS256' # 令牌过期时间 diff --git a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py index 57f51eb..73857bc 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta from typing import Annotated -import jwt from fastapi import Depends, Request, Response from sqlalchemy.ext.asyncio import AsyncSession @@ -27,6 +26,7 @@ from module_admin.entity.vo.user_vo import CurrentUserModel, EditUserModel from module_admin.service.login_service import CustomOAuth2PasswordRequestForm, LoginService, oauth2_scheme from module_admin.service.user_service import UserService +from utils.jwt_util import JwtUtil from utils.log_util import logger from utils.response_util import ResponseUtil @@ -205,9 +205,7 @@ async def register_user( ) @ApiCacheEvict(namespaces=ApiGroup.LOGOUT_MUTATION) async def logout(request: Request, token: Annotated[str | None, Depends(oauth2_scheme)]) -> Response: - payload = jwt.decode( - token, JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm], options={'verify_exp': False} - ) + payload = JwtUtil.decode(token, options={'verify_exp': False}) if AppConfig.app_same_time_login: token_id: str = payload.get('session_id') else: diff --git a/ruoyi-fastapi-backend/module_admin/service/login_service.py b/ruoyi-fastapi-backend/module_admin/service/login_service.py index 89bb473..5a0e52b 100644 --- a/ruoyi-fastapi-backend/module_admin/service/login_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/login_service.py @@ -3,10 +3,8 @@ from datetime import datetime, timedelta, timezone from typing import Any -import jwt from fastapi import Depends, Form, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from jwt.exceptions import InvalidTokenError from sqlalchemy import Row from sqlalchemy.ext.asyncio import AsyncSession @@ -27,6 +25,7 @@ from module_admin.service.user_service import UserService from utils.client_ip_util import ClientIPUtil from utils.common_util import CamelCaseUtil +from utils.jwt_util import JwtUtil from utils.log_util import logger from utils.message_util import message_service from utils.pwd_util import PwdUtil @@ -208,8 +207,7 @@ async def create_access_token(cls, data: dict, expires_delta: timedelta | None = else: expire = datetime.now(timezone.utc) + timedelta(minutes=30) to_encode.update({'exp': expire}) - encoded_jwt = jwt.encode(to_encode, JwtConfig.jwt_secret_key, algorithm=JwtConfig.jwt_algorithm) - return encoded_jwt + return JwtUtil.encode(to_encode) @classmethod async def get_current_user( @@ -228,16 +226,16 @@ async def get_current_user( # logger.warning("用户token不合法") # raise AuthException(data="", message="用户token不合法") try: - if token.startswith('Bearer'): - token = token.split(' ')[1] - payload = jwt.decode(token, JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm]) + if token.startswith('Bearer '): + token = token.removeprefix('Bearer ').strip() + payload = JwtUtil.decode(token) user_id: str = payload.get('user_id') session_id: str = payload.get('session_id') if not user_id: logger.warning('用户token不合法') raise AuthException(data='', message='用户token不合法') token_data = TokenData(user_id=int(user_id)) - except InvalidTokenError as e: + except (TypeError, ValueError) as e: logger.warning('用户token已失效,请重新登录') raise AuthException(data='', message='用户token已失效,请重新登录') from e query_user = await UserDao.get_user_by_id(query_db, user_id=token_data.user_id) diff --git a/ruoyi-fastapi-backend/module_admin/service/online_service.py b/ruoyi-fastapi-backend/module_admin/service/online_service.py index 6ed5070..432ff9f 100644 --- a/ruoyi-fastapi-backend/module_admin/service/online_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/online_service.py @@ -1,14 +1,14 @@ from typing import Any -import jwt from fastapi import Request from common.enums import RedisInitKeyConfig from common.vo import CrudResponseModel -from config.env import AppConfig, JwtConfig -from exceptions.exception import ServiceException +from config.env import AppConfig +from exceptions.exception import AuthException, ServiceException from module_admin.entity.vo.online_vo import DeleteOnlineModel, OnlineQueryModel from utils.common_util import CamelCaseUtil +from utils.jwt_util import JwtUtil class OnlineService: @@ -31,7 +31,11 @@ async def get_online_list_services(cls, request: Request, query_object: OnlineQu access_token_values_list = [await request.app.state.redis.get(key) for key in access_token_keys] online_info_list = [] for item in access_token_values_list: - payload = jwt.decode(item, JwtConfig.jwt_secret_key, algorithms=[JwtConfig.jwt_algorithm]) + try: + payload = JwtUtil.decode(item) + except AuthException: + # 单个过期或损坏的会话不应影响在线用户列表中的其他会话 + continue online_dict = { 'token_id': payload.get('session_id') if AppConfig.app_same_time_login else payload.get('user_id'), 'user_name': payload.get('user_name'), diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_login_service.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_login_service.py index f0fc7f1..7e64d84 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_login_service.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_login_service.py @@ -1,10 +1,12 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import jwt import pytest from common.enums import PasswordCharacterType -from exceptions.exception import ServiceException +from config.env import JwtConfig +from exceptions.exception import AuthException, ServiceException from module_admin.dao.user_dao import UserDao from module_admin.entity.vo.login_vo import UserRegister from module_admin.entity.vo.user_vo import CurrentUserModel @@ -35,6 +37,24 @@ def test_current_user_model_exposes_password_character_type_alias() -> None: assert current_user.model_dump(by_alias=True)['pwdChrtype'] == '3' +@pytest.mark.asyncio +async def test_get_current_user_rejects_malformed_user_id_claim() -> None: + token = jwt.encode( + {'user_id': 'invalid', 'session_id': 'session-id'}, + JwtConfig.jwt_secret_key, + algorithm=JwtConfig.jwt_algorithm, + ) + + with ( + patch.object(UserDao, 'get_user_by_id', new_callable=AsyncMock) as get_user, + pytest.raises(AuthException) as exc_info, + ): + await LoginService.get_current_user(SimpleNamespace(), token, object()) + + assert exc_info.value.message == '用户token已失效,请重新登录' + get_user.assert_not_awaited() + + @pytest.mark.asyncio async def test_unlock_screen_rejects_empty_password() -> None: with ( diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_online_service.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_online_service.py new file mode 100644 index 0000000..397f55c --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_online_service.py @@ -0,0 +1,25 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from exceptions.exception import AuthException +from module_admin.entity.vo.online_vo import OnlineQueryModel +from module_admin.service.online_service import OnlineService + + +@pytest.mark.asyncio +async def test_online_list_skips_tokens_that_cannot_be_decoded() -> None: + redis = SimpleNamespace( + keys=AsyncMock(return_value=['access_token:stale-session']), + get=AsyncMock(return_value='invalid-token'), + ) + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(redis=redis))) + + with patch( + 'module_admin.service.online_service.JwtUtil.decode', + side_effect=AuthException(data='', message='用户token已失效,请重新登录'), + ): + result = await OnlineService.get_online_list_services(request, OnlineQueryModel()) + + assert result == [] diff --git a/ruoyi-fastapi-backend/tests/utils/test_jwt_util.py b/ruoyi-fastapi-backend/tests/utils/test_jwt_util.py new file mode 100644 index 0000000..94bcf0e --- /dev/null +++ b/ruoyi-fastapi-backend/tests/utils/test_jwt_util.py @@ -0,0 +1,29 @@ +from unittest.mock import patch + +import pytest +from jwt.exceptions import DecodeError, InvalidAlgorithmError, InvalidKeyError + +from exceptions.exception import AuthException +from utils.jwt_util import JwtUtil + + +@pytest.mark.parametrize('jwt_error', [DecodeError(), InvalidAlgorithmError(), InvalidKeyError()]) +def test_decode_converts_pyjwt_errors_to_auth_exception(jwt_error: Exception) -> None: + with ( + patch('utils.jwt_util.jwt.decode', side_effect=jwt_error), + pytest.raises(AuthException) as exc_info, + ): + JwtUtil.decode('invalid-token') + + assert exc_info.value.message == '用户token已失效,请重新登录' + + +@pytest.mark.parametrize('jwt_error', [InvalidAlgorithmError(), InvalidKeyError()]) +def test_encode_converts_pyjwt_errors_to_auth_exception(jwt_error: Exception) -> None: + with ( + patch('utils.jwt_util.jwt.encode', side_effect=jwt_error), + pytest.raises(AuthException) as exc_info, + ): + JwtUtil.encode({'user_id': '1'}) + + assert exc_info.value.message == '用户token生成失败' diff --git a/ruoyi-fastapi-backend/utils/jwt_util.py b/ruoyi-fastapi-backend/utils/jwt_util.py new file mode 100644 index 0000000..ccc03b0 --- /dev/null +++ b/ruoyi-fastapi-backend/utils/jwt_util.py @@ -0,0 +1,48 @@ +from typing import Any + +import jwt +from jwt.exceptions import PyJWTError + +from config.env import JwtConfig +from exceptions.exception import AuthException +from utils.log_util import logger + + +class JwtUtil: + """ + JWT编解码工具类。 + """ + + @classmethod + def encode(cls, payload: dict[str, Any]) -> str: + """ + 生成JWT并将PyJWT异常转换为认证异常。 + + :param payload: JWT载荷 + :return: 编码后的JWT + """ + try: + return jwt.encode(payload, JwtConfig.jwt_secret_key, algorithm=JwtConfig.jwt_algorithm) + except PyJWTError as exc: + logger.warning(f'JWT生成失败:{type(exc).__name__}') + raise AuthException(data='', message='用户token生成失败') from exc + + @classmethod + def decode(cls, token: str | bytes, options: dict[str, Any] | None = None) -> dict[str, Any]: + """ + 解析JWT并将PyJWT异常转换为认证异常。 + + :param token: 待解析的JWT + :param options: PyJWT解析选项 + :return: JWT载荷 + """ + try: + return jwt.decode( + token, + JwtConfig.jwt_secret_key, + algorithms=[JwtConfig.jwt_algorithm], + options=options, + ) + except PyJWTError as exc: + logger.warning(f'JWT校验失败:{type(exc).__name__}') + raise AuthException(data='', message='用户token已失效,请重新登录') from exc From b3d30b4463c56768acb8901db16475003e0a952b Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Fri, 31 Jul 2026 21:21:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=9C=A8windows=E4=B8=8B=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/completion/installers.py | 3 ++- ruoyi-fastapi-backend/cli/completion/providers.py | 5 +++-- ruoyi-fastapi-backend/cli/guards.py | 2 +- ruoyi-fastapi-backend/cli/runtime/app/support.py | 5 +++-- ruoyi-fastapi-backend/cli/runtime/db/support.py | 6 ++++-- .../cli/runtime/plugin/scaffold/payload.py | 5 +++-- ruoyi-fastapi-backend/cli/utils.py | 11 +++++++++++ .../plugins/core/validation/dependency_policy.py | 15 +++++++++------ 8 files changed, 36 insertions(+), 16 deletions(-) diff --git a/ruoyi-fastapi-backend/cli/completion/installers.py b/ruoyi-fastapi-backend/cli/completion/installers.py index bdbdf38..92ef1d0 100644 --- a/ruoyi-fastapi-backend/cli/completion/installers.py +++ b/ruoyi-fastapi-backend/cli/completion/installers.py @@ -14,6 +14,7 @@ from cli.completion.shells import PowerShellComplete, ensure_custom_completion_classes_registered from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR from cli.metadata import COMPLETION_SHELL_SPEC_REGISTRY, CompletionShellSpec, CompletionShellSpecRegistry +from cli.utils import format_cli_path CLICK_COMPLETE_ENV_VAR = '_RUOYI_COMPLETE' @@ -321,7 +322,7 @@ def build_source_command(self, target_file: Path, shell: str) -> str: :return: 激活命令文本 """ runtime_policy = self.resolve_shell_runtime_policy(shell) - return runtime_policy.source_command_builder(target_file) + return runtime_policy.source_command_builder(format_cli_path(target_file)) @staticmethod def detect_active_shell() -> str: diff --git a/ruoyi-fastapi-backend/cli/completion/providers.py b/ruoyi-fastapi-backend/cli/completion/providers.py index 1b9dadf..c8043e9 100644 --- a/ruoyi-fastapi-backend/cli/completion/providers.py +++ b/ruoyi-fastapi-backend/cli/completion/providers.py @@ -13,6 +13,7 @@ CompletionShellSpecRegistry, EnvironmentOptionService, ) +from cli.utils import format_cli_path DEFAULT_ALEMBIC_REVISION_CHOICES = ('head', 'base', 'current', '-1') DYNAMIC_COMPLETION_TIMEOUT_SECONDS = 0.8 @@ -118,9 +119,9 @@ def to_display_path(path: Path, *, project_dir: Path) -> str: """ try: relative_path = path.relative_to(project_dir) - return str(relative_path) or '.' + return format_cli_path(relative_path) or '.' except ValueError: - return str(path) + return format_cli_path(path) @dataclass diff --git a/ruoyi-fastapi-backend/cli/guards.py b/ruoyi-fastapi-backend/cli/guards.py index f92c458..a32c531 100644 --- a/ruoyi-fastapi-backend/cli/guards.py +++ b/ruoyi-fastapi-backend/cli/guards.py @@ -114,7 +114,7 @@ def confirm(self, ctx: CliContext, *, command_name: str) -> CommandResult | None if ctx.yes: return None - if not sys.stdin.isatty(): + if not sys.stdin.isatty() or not sys.stdout.isatty(): return self.result_builder.build_guard_reject_result( f'已取消危险命令执行:{command_name}', '当前命令需要交互确认;如需非交互执行,请传入 --yes', diff --git a/ruoyi-fastapi-backend/cli/runtime/app/support.py b/ruoyi-fastapi-backend/cli/runtime/app/support.py index 169054c..882b789 100644 --- a/ruoyi-fastapi-backend/cli/runtime/app/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/app/support.py @@ -3,6 +3,7 @@ from typing import Any from cli.runtime.base import RuntimeEnvironmentService +from cli.utils import format_cli_path from .gateway import AppInfrastructureGateway @@ -83,8 +84,8 @@ def build_app_env_snapshot(self) -> dict[str, Any]: 'configEnv': app_config.app_env, 'appEnv': os.environ.get('APP_ENV', ''), 'envFile': env_file_name, - 'envFilePath': str(env_file_path), + 'envFilePath': format_cli_path(env_file_path), 'envFileExists': env_file_path.exists(), - 'backendDir': str(backend_dir), + 'backendDir': format_cli_path(backend_dir), 'pythonExecutable': self.runtime_environment.get_python_executable(), } diff --git a/ruoyi-fastapi-backend/cli/runtime/db/support.py b/ruoyi-fastapi-backend/cli/runtime/db/support.py index 2f1cfcc..d95014e 100644 --- a/ruoyi-fastapi-backend/cli/runtime/db/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/db/support.py @@ -1,9 +1,11 @@ import os import subprocess +from pathlib import Path from typing import Any from cli.exit_codes import DATABASE_ERROR from cli.runtime.base import RuntimeEnvironmentService +from cli.utils import format_cli_path from .gateway import DatabaseInfrastructureGateway @@ -72,7 +74,7 @@ def serialize_revision(self, script_revision: Any) -> dict[str, Any]: 'branchLabels': sorted(str(item) for item in script_revision.branch_labels or []), 'dependsOn': self.normalize_revision_value(script_revision.dependencies), 'doc': (script_revision.doc or '').strip(), - 'path': str(script_revision.path), + 'path': format_cli_path(script_revision.path), } @@ -103,7 +105,7 @@ def build_alembic_command(self, command: str, *arguments: str) -> list[str]: :param arguments: Alembic 子命令参数列表 :return: Alembic 命令参数列表 """ - alembic_ini_path = os.path.join(self.runtime_environment.get_backend_dir(), 'alembic.ini') + alembic_ini_path = format_cli_path(Path(self.runtime_environment.get_backend_dir()) / 'alembic.ini') return ['alembic', '-c', alembic_ini_path, command, *arguments] def run_alembic_command( diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py index 687185e..2f8ac25 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py @@ -3,6 +3,7 @@ from typing import Any from cli.exit_codes import RUNTIME_ERROR +from cli.utils import format_cli_path @dataclass(frozen=True) @@ -46,8 +47,8 @@ def to_payload(self) -> dict[str, Any]: 'backendTest': self.backend_test, 'frontendTest': self.frontend_test, 'frontendVersion': self.frontend_version, - 'targetDirs': self.target_dirs, - 'files': [{'path': str(path), 'content': content} for path, content in self.files], + 'targetDirs': [format_cli_path(path) for path in self.target_dirs], + 'files': [{'path': path.as_posix(), 'content': content} for path, content in self.files], 'conflicts': self.conflicts, } diff --git a/ruoyi-fastapi-backend/cli/utils.py b/ruoyi-fastapi-backend/cli/utils.py index 28e4221..a305a94 100644 --- a/ruoyi-fastapi-backend/cli/utils.py +++ b/ruoyi-fastapi-backend/cli/utils.py @@ -15,6 +15,17 @@ _ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]') +def format_cli_path(path: Path | str) -> str: + """ + 格式化 CLI 对外展示路径。 + + Windows 绝对路径保留盘符和原生格式;无盘符路径使用 POSIX 分隔符, + 以保证虚拟 Unix 路径和跨平台 payload 的稳定性。 + """ + path_object = Path(path) + return str(path_object) if path_object.drive else path_object.as_posix() + + @dataclass(frozen=True) class NestedCliResult: """ diff --git a/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py b/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py index 963ead4..09b3b91 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py @@ -56,12 +56,15 @@ def __post_init__(self) -> None: """ object.__setattr__(self, 'env', (self.env or 'dev').strip() or 'dev') object.__setattr__(self, 'mode', self.mode or self._default_mode(self.env)) - if self.lockfile_path is not None: - object.__setattr__(self, 'lockfile_path', Path(self.lockfile_path)) - if self.offline_dir is not None: - object.__setattr__(self, 'offline_dir', Path(self.offline_dir)) - if self.allowlist_path is not None: - object.__setattr__(self, 'allowlist_path', Path(self.allowlist_path)) + for field_name in ('lockfile_path', 'offline_dir', 'allowlist_path'): + value = getattr(self, field_name) + if isinstance(value, str): + path_value = Path(value) + object.__setattr__( + self, + field_name, + path_value if path_value.is_absolute() else value.replace('\\', '/'), + ) @classmethod def from_environment(