Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ruoyi-fastapi-backend/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -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'
# 令牌过期时间
Expand Down
3 changes: 2 additions & 1 deletion ruoyi-fastapi-backend/cli/completion/installers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions ruoyi-fastapi-backend/cli/completion/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ruoyi-fastapi-backend/cli/guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions ruoyi-fastapi-backend/cli/runtime/app/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(),
}
6 changes: 4 additions & 2 deletions ruoyi-fastapi-backend/cli/runtime/db/support.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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),
}


Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}

Expand Down
11 changes: 11 additions & 0 deletions ruoyi-fastapi-backend/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
14 changes: 6 additions & 8 deletions ruoyi-fastapi-backend/module_admin/service/login_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
12 changes: 8 additions & 4 deletions ruoyi-fastapi-backend/module_admin/service/online_service.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 == []
29 changes: 29 additions & 0 deletions ruoyi-fastapi-backend/tests/utils/test_jwt_util.py
Original file line number Diff line number Diff line change
@@ -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生成失败'
Loading
Loading