From 46767c9ac435933fa6b9f7af815863f297f80ceb Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 28 Oct 2026 17:20:44 +0800 Subject: [PATCH 1/7] =?UTF-8?q?perf:=20=E7=B2=BE=E7=AE=80=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E9=89=B4=E6=9D=83=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../module_admin/dao/user_dao.py | 90 ++++--- .../dao/test_user_auth_queries.py | 225 ++++++++++++++++++ 2 files changed, 265 insertions(+), 50 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/module_admin/dao/test_user_auth_queries.py diff --git a/ruoyi-fastapi-backend/module_admin/dao/user_dao.py b/ruoyi-fastapi-backend/module_admin/dao/user_dao.py index ee15fdb82..9e60c5e45 100644 --- a/ruoyi-fastapi-backend/module_admin/dao/user_dao.py +++ b/ruoyi-fastapi-backend/module_admin/dao/user_dao.py @@ -93,41 +93,35 @@ async def get_user_by_info(cls, db: AsyncSession, user: UserModel) -> SysUser | return query_user_info @classmethod - async def get_user_by_id(cls, db: AsyncSession, user_id: int) -> dict[str, Any]: + async def get_user_by_id(cls, db: AsyncSession, user_id: int, *, for_auth: bool = False) -> dict[str, Any]: """ 根据user_id获取用户信息 :param db: orm对象 :param user_id: 用户id + :param for_auth: 是否仅查询鉴权所需的菜单权限字段 :return: 当前user_id的用户信息对象 """ - query_user_basic_info = ( - ( - await db.execute( - select(SysUser) - .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) - .distinct() - ) - ) - .scalars() - .first() - ) - query_user_dept_info = ( - ( - await db.execute( - select(SysDept) - .select_from(SysUser) - .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) - .join( - SysDept, - and_(SysUser.dept_id == SysDept.dept_id, SysDept.status == '0', SysDept.del_flag == '0'), - ) - .distinct() + query_user_info = ( + await db.execute( + select(SysUser, SysDept) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .join( + SysDept, + and_(SysUser.dept_id == SysDept.dept_id, SysDept.status == '0', SysDept.del_flag == '0'), + isouter=True, ) ) - .scalars() - .first() - ) + ).first() + if query_user_info is None: + return { + 'user_basic_info': None, + 'user_dept_info': None, + 'user_role_info': [], + 'user_post_info': [], + 'user_menu_info': [], + } + query_user_basic_info, query_user_dept_info = query_user_info query_user_role_info = ( ( await db.execute( @@ -160,34 +154,30 @@ async def get_user_by_id(cls, db: AsyncSession, user_id: int) -> dict[str, Any]: .all() ) role_id_list = [item.role_id for item in query_user_role_info] + query_user_menu_info = [] if 1 in role_id_list: - query_user_menu_info = ( - (await db.execute(select(SysMenu).where(SysMenu.status == '0').distinct())).scalars().all() - ) + # 管理员鉴权使用全部权限标识,获取路由时仍需查询完整菜单 + if not for_auth: + query_user_menu_info = ( + (await db.execute(select(SysMenu).where(SysMenu.status == '0').distinct())).scalars().all() + ) else: - query_user_menu_info = ( - ( - await db.execute( - select(SysMenu) - .select_from(SysUser) - .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) - .join(SysUserRole, SysUser.user_id == SysUserRole.user_id, isouter=True) - .join( - SysRole, - and_( - SysUserRole.role_id == SysRole.role_id, SysRole.status == '0', SysRole.del_flag == '0' - ), - isouter=True, - ) - .join(SysRoleMenu, SysRole.role_id == SysRoleMenu.role_id, isouter=True) - .join(SysMenu, and_(SysRoleMenu.menu_id == SysMenu.menu_id, SysMenu.status == '0')) - .order_by(SysMenu.order_num) - .distinct() - ) + menu_query = select(SysMenu.menu_id, SysMenu.perms, SysMenu.order_num) if for_auth else select(SysMenu) + query_user_menu_result = await db.execute( + menu_query.select_from(SysUser) + .where(SysUser.status == '0', SysUser.del_flag == '0', SysUser.user_id == user_id) + .join(SysUserRole, SysUser.user_id == SysUserRole.user_id, isouter=True) + .join( + SysRole, + and_(SysUserRole.role_id == SysRole.role_id, SysRole.status == '0', SysRole.del_flag == '0'), + isouter=True, ) - .scalars() - .all() + .join(SysRoleMenu, SysRole.role_id == SysRoleMenu.role_id, isouter=True) + .join(SysMenu, and_(SysRoleMenu.menu_id == SysMenu.menu_id, SysMenu.status == '0')) + .order_by(SysMenu.order_num) + .distinct() ) + query_user_menu_info = query_user_menu_result.all() if for_auth else query_user_menu_result.scalars().all() results = { 'user_basic_info': query_user_basic_info, diff --git a/ruoyi-fastapi-backend/tests/module_admin/dao/test_user_auth_queries.py b/ruoyi-fastapi-backend/tests/module_admin/dao/test_user_auth_queries.py new file mode 100644 index 000000000..d04bdf715 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/dao/test_user_auth_queries.py @@ -0,0 +1,225 @@ +from collections.abc import AsyncIterator +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from sqlalchemy import delete, event, update +from sqlalchemy.dialects.mysql import TINYINT +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.compiler import compiles + +from config.database import Base +from module_admin.dao.user_dao import UserDao +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.post_do import SysPost +from module_admin.entity.do.role_do import SysRole, SysRoleMenu +from module_admin.entity.do.user_do import SysUser, SysUserPost, SysUserRole + +USER_ID = 20 +ADMIN_ID = 10 +ROLE_ID = 20 +SECOND_ROLE_ID = 21 +DISABLED_ROLE_ID = 22 +DELETED_ROLE_ID = 23 +DEPT_ID = 20 +POST_ID = 20 +DISABLED_POST_ID = 21 +MENU_ID = 20 +SECOND_MENU_ID = 21 +DISABLED_MENU_ID = 22 +UNAUTHORIZED_MENU_ID = 23 +UNASSIGNED_MENU_ID = 24 +EMPTY_PERMISSION_MENU_ID = 25 +USER_QUERY_COUNT = 4 +ADMIN_QUERY_COUNT = 3 + + +@compiles(TINYINT, 'sqlite') +def _compile_tinyint(_type: TINYINT, _compiler: object, **kwargs: object) -> str: + return 'SMALLINT' + + +@pytest_asyncio.fixture +async def user_query_env() -> AsyncIterator[SimpleNamespace]: + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + tables = [SysUser, SysDept, SysRole, SysPost, SysMenu, SysUserRole, SysUserPost, SysRoleMenu] + sessions = async_sessionmaker(engine, expire_on_commit=False, autoflush=False) + async with engine.begin() as connection: + await connection.run_sync( + lambda conn: Base.metadata.create_all(conn, tables=[model.__table__ for model in tables]) + ) + async with sessions.begin() as db: + db.add_all( + [ + SysUser(user_id=USER_ID, user_name='reader', nick_name='Reader', dept_id=DEPT_ID, time_zone='UTC'), + SysUser(user_id=ADMIN_ID, user_name='admin', nick_name='Admin', dept_id=DEPT_ID), + SysDept(dept_id=DEPT_ID, dept_name='Engineering', ancestors='0,1'), + SysRole(role_id=1, role_name='Admin', role_key='admin', role_sort=0, status='0'), + SysRole( + role_id=ROLE_ID, role_name='Reader', role_key='reader', role_sort=1, status='0', data_scope='3' + ), + SysRole(role_id=SECOND_ROLE_ID, role_name='Other', role_key='other', role_sort=2, status='0'), + SysRole(role_id=DISABLED_ROLE_ID, role_name='Disabled', role_key='disabled', role_sort=3, status='1'), + SysRole( + role_id=DELETED_ROLE_ID, + role_name='Deleted', + role_key='deleted', + role_sort=4, + status='0', + del_flag='2', + ), + SysPost(post_id=POST_ID, post_name='Developer', post_code='dev', post_sort=1, status='0'), + SysPost(post_id=DISABLED_POST_ID, post_name='Disabled', post_code='disabled', post_sort=2, status='1'), + SysMenu( + menu_id=MENU_ID, + menu_name='Read', + perms='demo:read', + order_num=2, + path='demo', + component='demo/index', + route_name='Demo', + menu_type='C', + icon='list', + ), + SysMenu(menu_id=SECOND_MENU_ID, menu_name='Read again', perms='demo:read', order_num=1), + SysMenu(menu_id=DISABLED_MENU_ID, menu_name='Disabled', perms='demo:disabled', status='1'), + SysMenu(menu_id=UNAUTHORIZED_MENU_ID, menu_name='Unauthorized', perms='demo:unauthorized'), + SysMenu(menu_id=UNASSIGNED_MENU_ID, menu_name='Unassigned', perms='demo:unassigned'), + SysMenu(menu_id=EMPTY_PERMISSION_MENU_ID, menu_name='Directory', perms=None, order_num=3), + SysUserRole(user_id=ADMIN_ID, role_id=1), + *( + SysUserRole(user_id=USER_ID, role_id=role_id) + for role_id in [ROLE_ID, SECOND_ROLE_ID, DISABLED_ROLE_ID, DELETED_ROLE_ID] + ), + SysUserPost(user_id=USER_ID, post_id=POST_ID), + SysUserPost(user_id=USER_ID, post_id=DISABLED_POST_ID), + SysRoleMenu(role_id=ROLE_ID, menu_id=MENU_ID), + SysRoleMenu(role_id=SECOND_ROLE_ID, menu_id=MENU_ID), + SysRoleMenu(role_id=ROLE_ID, menu_id=SECOND_MENU_ID), + SysRoleMenu(role_id=ROLE_ID, menu_id=DISABLED_MENU_ID), + SysRoleMenu(role_id=ROLE_ID, menu_id=EMPTY_PERMISSION_MENU_ID), + SysRoleMenu(role_id=DISABLED_ROLE_ID, menu_id=UNAUTHORIZED_MENU_ID), + SysRoleMenu(role_id=DELETED_ROLE_ID, menu_id=UNAUTHORIZED_MENU_ID), + ] + ) + statements = [] + + @event.listens_for(engine.sync_engine, 'before_cursor_execute') + def record_query( + connection: object, cursor: object, statement: str, parameters: object, context: object, executemany: bool + ) -> None: + if statement.lstrip().upper().startswith('SELECT'): + statements.append(statement) + + try: + yield SimpleNamespace(sessions=sessions, statements=statements) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_user_auth_preserves_profile_roles_posts_and_permissions(user_query_env: SimpleNamespace) -> None: + async with user_query_env.sessions() as db: + result = await UserDao.get_user_by_id(db, USER_ID, for_auth=True) + assert len(user_query_env.statements) == USER_QUERY_COUNT + assert result['user_basic_info'].user_name == 'reader' + assert result['user_basic_info'].time_zone == 'UTC' + assert result['user_dept_info'].dept_name == 'Engineering' + assert result['user_dept_info'].ancestors == '0,1' + assert {role.role_id for role in result['user_role_info']} == {ROLE_ID, SECOND_ROLE_ID} + assert next(role for role in result['user_role_info'] if role.role_id == ROLE_ID).data_scope == '3' + assert [post.post_id for post in result['user_post_info']] == [POST_ID] + assert [menu.menu_id for menu in result['user_menu_info']] == [SECOND_MENU_ID, MENU_ID, EMPTY_PERMISSION_MENU_ID] + assert [menu.perms for menu in result['user_menu_info']] == ['demo:read', 'demo:read', None] + assert set(result['user_menu_info'][0]._mapping) == {'menu_id', 'perms', 'order_num'} + + +@pytest.mark.asyncio +async def test_admin_auth_skips_menus_but_keeps_roles_and_profile(user_query_env: SimpleNamespace) -> None: + async with user_query_env.sessions() as db: + result = await UserDao.get_user_by_id(db, ADMIN_ID, for_auth=True) + assert len(user_query_env.statements) == ADMIN_QUERY_COUNT + assert result['user_basic_info'].user_name == 'admin' + assert [role.role_id for role in result['user_role_info']] == [1] + assert result['user_dept_info'].dept_id == DEPT_ID + assert result['user_menu_info'] == [] + assert not any('sys_menu' in statement for statement in user_query_env.statements) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('for_auth', [True, False]) +@pytest.mark.parametrize('condition', ['disabled', 'deleted', 'missing']) +async def test_invalid_user_returns_after_one_query( + user_query_env: SimpleNamespace, for_auth: bool, condition: str +) -> None: + async with user_query_env.sessions.begin() as db: + if condition == 'missing': + await db.execute(delete(SysUser).where(SysUser.user_id == USER_ID)) + else: + values = {'status': '1'} if condition == 'disabled' else {'del_flag': '2'} + await db.execute(update(SysUser).where(SysUser.user_id == USER_ID).values(**values)) + async with user_query_env.sessions() as db: + result = await UserDao.get_user_by_id(db, USER_ID, for_auth=for_auth) + assert len(user_query_env.statements) == 1 + assert result == { + 'user_basic_info': None, + 'user_dept_info': None, + 'user_role_info': [], + 'user_post_info': [], + 'user_menu_info': [], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize('condition', ['disabled', 'deleted', 'missing', 'unassigned']) +async def test_invalid_department_does_not_hide_valid_user(user_query_env: SimpleNamespace, condition: str) -> None: + async with user_query_env.sessions.begin() as db: + if condition == 'unassigned': + await db.execute(update(SysUser).where(SysUser.user_id == USER_ID).values(dept_id=None)) + elif condition == 'missing': + await db.execute(delete(SysDept).where(SysDept.dept_id == DEPT_ID)) + else: + values = {'status': '1'} if condition == 'disabled' else {'del_flag': '2'} + await db.execute(update(SysDept).where(SysDept.dept_id == DEPT_ID).values(**values)) + async with user_query_env.sessions() as db: + result = await UserDao.get_user_by_id(db, USER_ID, for_auth=True) + assert len(user_query_env.statements) == USER_QUERY_COUNT + assert result['user_basic_info'].user_id == USER_ID + assert result['user_dept_info'] is None + assert [menu.perms for menu in result['user_menu_info']] == ['demo:read', 'demo:read', None] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('user_id', [USER_ID, ADMIN_ID]) +async def test_route_query_keeps_complete_menu_models(user_query_env: SimpleNamespace, user_id: int) -> None: + async with user_query_env.sessions() as db: + result = await UserDao.get_user_by_id(db, user_id) + assert len(user_query_env.statements) == USER_QUERY_COUNT + menus = result['user_menu_info'] + assert all(isinstance(menu, SysMenu) for menu in menus) + route = next(menu for menu in menus if menu.menu_id == MENU_ID) + assert route.path == 'demo' + assert route.component == 'demo/index' + assert route.route_name == 'Demo' + assert route.menu_type == 'C' + assert route.icon == 'list' + assert DISABLED_MENU_ID not in {menu.menu_id for menu in menus} + if user_id == ADMIN_ID: + assert UNASSIGNED_MENU_ID in {menu.menu_id for menu in menus} + else: + assert [menu.menu_id for menu in menus] == [SECOND_MENU_ID, MENU_ID, EMPTY_PERMISSION_MENU_ID] + + +@pytest.mark.asyncio +async def test_new_request_observes_committed_permission_changes(user_query_env: SimpleNamespace) -> None: + async with user_query_env.sessions() as db: + before = await UserDao.get_user_by_id(db, USER_ID, for_auth=True) + async with user_query_env.sessions.begin() as db: + await db.execute(update(SysRole).where(SysRole.role_id == ROLE_ID).values(status='1')) + await db.execute(update(SysMenu).where(SysMenu.menu_id == MENU_ID).values(perms='demo:changed')) + async with user_query_env.sessions() as db: + after = await UserDao.get_user_by_id(db, USER_ID, for_auth=True) + assert [menu.perms for menu in before['user_menu_info']] == ['demo:read', 'demo:read', None] + assert [menu.perms for menu in after['user_menu_info']] == ['demo:changed'] + assert [role.role_id for role in after['user_role_info']] == [SECOND_ROLE_ID] From 0f2e69056778921f877f622bfe923bbff9f9e78d Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 28 Oct 2026 17:20:44 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=BB=AD=E6=9C=9F=E4=B8=8E=E9=80=80=E5=87=BA=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-fastapi-backend/config/env.py | 2 +- .../controller/login_controller.py | 2 +- .../module_admin/service/login_service.py | 152 ++--- .../tests/config/test_jwt_settings.py | 25 + .../controller/test_login_logout.py | 160 +++++ .../service/test_login_session_renewal.py | 560 ++++++++++++++++++ .../core/runtime/test_browser_session.py | 12 +- 7 files changed, 838 insertions(+), 75 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/module_admin/controller/test_login_logout.py create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_login_session_renewal.py diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index 8f1a133d5..e3ed165d1 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -63,7 +63,7 @@ class JwtSettings(BaseSettings): jwt_secret_key: str = Field(default_factory=lambda: secrets.token_hex(32)) jwt_algorithm: str = 'HS256' jwt_expire_minutes: int = 1440 - jwt_redis_expire_minutes: int = 30 + jwt_redis_expire_minutes: int = Field(default=30, gt=0) @field_validator('jwt_secret_key', mode='before') @classmethod diff --git a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py index 428e3dece..9b4a5171c 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py @@ -211,7 +211,7 @@ async def logout(request: Request, token: Annotated[str | None, Depends(oauth2_s token_id: str = payload.get('session_id') else: token_id: str = payload.get('user_id') - await LoginService.logout_services(request, token_id) + await LoginService.logout_services(request, token_id, token) logger.info('退出成功') return ResponseUtil.success(msg='退出成功') diff --git a/ruoyi-fastapi-backend/module_admin/service/login_service.py b/ruoyi-fastapi-backend/module_admin/service/login_service.py index b7692c105..8c2d1810d 100644 --- a/ruoyi-fastapi-backend/module_admin/service/login_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/login_service.py @@ -36,6 +36,18 @@ from utils.time_util import TimezoneUtil oauth2_scheme = OAuth2PasswordBearer(tokenUrl='login') +_SESSION_RENEW_SCRIPT = """ +if redis.call('get', KEYS[1]) ~= ARGV[1] then + return 0 +end +return redis.call('expire', KEYS[1], ARGV[2]) +""" +_SESSION_LOGOUT_SCRIPT = """ +if redis.call('get', KEYS[1]) ~= ARGV[1] then + return 0 +end +return redis.call('del', KEYS[1]) +""" class CustomOAuth2PasswordRequestForm(OAuth2PasswordRequestForm): @@ -160,7 +172,7 @@ async def get_current_user_for_plugin_session( cls, request: Request, token: str, query_db: AsyncSession ) -> CurrentUserModel: """ - 插件 Cookie 只验证主会话,不通过 GET/SET 续期复活并发退出的登录。 + 插件 Cookie 只验证主会话,不续期主登录会话。 :param request: 当前插件请求 :param token: 插件会话绑定的宿主登录凭证 @@ -182,9 +194,6 @@ async def _resolve_current_user( :param refresh_session: 是否续期宿主主登录会话 :return: 通过宿主身份及实时权限校验的当前用户 """ - # if token[:6] != 'Bearer': - # logger.warning("用户token不合法") - # raise AuthException(data="", message="用户token不合法") try: if token.startswith('Bearer '): token = token.removeprefix('Bearer ').strip() @@ -198,67 +207,74 @@ async def _resolve_current_user( 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) - if query_user.get('user_basic_info') is None: - logger.warning('用户token不合法') - raise AuthException(data='', message='用户token不合法') - if AppConfig.app_same_time_login: - redis_token = await request.app.state.redis.get(f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}') - else: - # 此方法可实现同一账号同一时间只能登录一次 - redis_token = await request.app.state.redis.get( - f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{query_user.get("user_basic_info").user_id}' - ) - if token == redis_token: - if refresh_session and AppConfig.app_same_time_login: - await request.app.state.redis.set( - f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}', - redis_token, - ex=timedelta(minutes=JwtConfig.jwt_redis_expire_minutes), - ) - elif refresh_session: - await request.app.state.redis.set( - f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{query_user.get("user_basic_info").user_id}', - redis_token, - ex=timedelta(minutes=JwtConfig.jwt_redis_expire_minutes), + redis = request.app.state.redis + token_key = f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id if AppConfig.app_same_time_login else token_data.user_id}' + redis_token = await redis.get(token_key) + if token != redis_token: + logger.warning('用户token已失效,请重新登录') + raise AuthException(data='', message='用户token已失效,请重新登录') + + current_user = await cls._get_current_user_from_db(query_db, token_data.user_id) + # 密码策略及过期状态在本次鉴权时计算 + current_user.app_timezone = AppConfig.app_timezone + current_user.pwd_chrtype = await cls.get_sys_account_chrtype(request) + current_user.is_default_modify_pwd = await cls.__init_password_is_modify( + request, current_user.user.pwd_update_date + ) + current_user.is_password_expired = await cls.__password_is_expired(request, current_user.user.pwd_update_date) + if refresh_session: + # 原子校验令牌并续期,避免旧请求覆盖并发登录的新令牌或恢复已退出的会话 + session_valid = ( + await redis.eval( + _SESSION_RENEW_SCRIPT, + 1, + token_key, + token, + JwtConfig.jwt_redis_expire_minutes * 60, ) - - role_id_list = [item.role_id for item in query_user.get('user_role_info')] - if 1 in role_id_list: # noqa: SIM108 - permissions = ['*:*:*'] - else: - permissions = [row.perms for row in query_user.get('user_menu_info')] - post_ids = ','.join([str(row.post_id) for row in query_user.get('user_post_info')]) - role_ids = ','.join([str(row.role_id) for row in query_user.get('user_role_info')]) - roles = [row.role_key for row in query_user.get('user_role_info')] - is_default_modify_pwd = await cls.__init_password_is_modify( - request, query_user.get('user_basic_info').pwd_update_date - ) - is_password_expired = await cls.__password_is_expired( - request, query_user.get('user_basic_info').pwd_update_date - ) - pwd_chrtype = await cls.get_sys_account_chrtype(request) - - current_user = CurrentUserModel( - permissions=permissions, - roles=roles, - appTimezone=AppConfig.app_timezone, - user=UserInfoModel( - **CamelCaseUtil.transform_result(query_user.get('user_basic_info')), - postIds=post_ids, - roleIds=role_ids, - dept=CamelCaseUtil.transform_result(query_user.get('user_dept_info')), - role=CamelCaseUtil.transform_result(query_user.get('user_role_info')), - ), - pwdChrtype=pwd_chrtype, - isDefaultModifyPwd=is_default_modify_pwd, - isPasswordExpired=is_password_expired, + == 1 ) - # 设置当前用户信息到上下文 - RequestContext.set_current_user(current_user) - return current_user - logger.warning('用户token已失效,请重新登录') - raise AuthException(data='', message='用户token已失效,请重新登录') + else: + # 插件不续期主会话,查询用户后仍需复核令牌,避免接受期间已退出或替换的会话 + session_valid = await redis.get(token_key) == token + if not session_valid: + logger.warning('用户token已失效,请重新登录') + raise AuthException(data='', message='用户token已失效,请重新登录') + RequestContext.set_current_user(current_user) + return current_user + + @classmethod + async def _get_current_user_from_db(cls, query_db: AsyncSession, user_id: int) -> CurrentUserModel: + """ + 查询当前用户资料和权限 + + :param query_db: orm对象 + :param user_id: 用户id + :return: 当前用户信息对象 + """ + query_user = await UserDao.get_user_by_id(query_db, user_id=user_id, for_auth=True) + basic_info = query_user.get('user_basic_info') + if basic_info is None: + raise AuthException(data='', message='用户token不合法') + + roles = query_user.get('user_role_info') or [] + user_info = CamelCaseUtil.transform_result(basic_info) + # 当前用户资料不返回密码哈希,密码校验单独查询数据库 + user_info.pop('password', None) + current_user = CurrentUserModel( + permissions=['*:*:*'] + if any(role.role_id == 1 for role in roles) + else [menu.perms for menu in query_user.get('user_menu_info') or []], + roles=[role.role_key for role in roles], + user=UserInfoModel( + **user_info, + postIds=','.join(str(post.post_id) for post in query_user.get('user_post_info') or []), + roleIds=','.join(str(role.role_id) for role in roles), + dept=CamelCaseUtil.transform_result(query_user.get('user_dept_info')), + role=CamelCaseUtil.transform_result(roles), + ), + ) + return current_user @classmethod async def get_sys_account_chrtype(cls, request: Request) -> str: @@ -530,17 +546,19 @@ async def forget_user_services( return CrudResponseModel(**result) @classmethod - async def logout_services(cls, request: Request, token_id: str) -> bool: + async def logout_services(cls, request: Request, token_id: str, token: str) -> bool: """ 退出登录services :param request: Request对象 :param token_id: 令牌编号 + :param token: 当前请求携带的登录令牌 :return: 退出登录结果 """ - await request.app.state.redis.delete(f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{token_id}') - # await request.app.state.redis.delete(f'{current_user.user.user_id}_access_token') - # await request.app.state.redis.delete(f'{current_user.user.user_id}_session_id') + # 原子校验令牌并删除会话,避免旧令牌退出时删除并发登录的新会话 + await request.app.state.redis.eval( + _SESSION_LOGOUT_SCRIPT, 1, f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{token_id}', token + ) return True diff --git a/ruoyi-fastapi-backend/tests/config/test_jwt_settings.py b/ruoyi-fastapi-backend/tests/config/test_jwt_settings.py index b1a770b27..b916d773e 100644 --- a/ruoyi-fastapi-backend/tests/config/test_jwt_settings.py +++ b/ruoyi-fastapi-backend/tests/config/test_jwt_settings.py @@ -1,6 +1,7 @@ from unittest.mock import patch import pytest +from pydantic import ValidationError from config.env import JwtSettings @@ -37,3 +38,27 @@ def test_configured_jwt_secret_key_is_preserved(monkeypatch: pytest.MonkeyPatch) token_hex.assert_not_called() assert settings.jwt_secret_key == configured_secret + + +@pytest.mark.parametrize('minutes', [0, -1]) +def test_redis_session_expiration_rejects_nonpositive_minutes(minutes: int) -> None: + """会话续期时间必须为正数,避免EXPIRE删除会话后仍被视为续期成功。""" + with pytest.raises(ValidationError, match='jwt_redis_expire_minutes'): + JwtSettings(jwt_redis_expire_minutes=minutes) + + +@pytest.mark.parametrize('minutes', ['0', '-1']) +def test_redis_session_expiration_rejects_invalid_environment(monkeypatch: pytest.MonkeyPatch, minutes: str) -> None: + """环境变量中的非法会话有效期应在加载配置时被拒绝。""" + monkeypatch.setenv('JWT_REDIS_EXPIRE_MINUTES', minutes) + + with pytest.raises(ValidationError, match='jwt_redis_expire_minutes'): + JwtSettings() + + +@pytest.mark.parametrize('minutes', ['1', '30']) +def test_redis_session_expiration_accepts_positive_environment(monkeypatch: pytest.MonkeyPatch, minutes: str) -> None: + """保留合法环境变量的分钟单位和取值。""" + monkeypatch.setenv('JWT_REDIS_EXPIRE_MINUTES', minutes) + + assert JwtSettings().jwt_redis_expire_minutes == int(minutes) diff --git a/ruoyi-fastapi-backend/tests/module_admin/controller/test_login_logout.py b/ruoyi-fastapi-backend/tests/module_admin/controller/test_login_logout.py new file mode 100644 index 000000000..594d7b8dd --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/controller/test_login_logout.py @@ -0,0 +1,160 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest +from fastapi import FastAPI, Request, status +from redis.exceptions import TimeoutError as RedisTimeoutError + +from common.annotation.cache_annotation import ApiCacheManager +from common.constant import ApiGroup +from config.env import AppConfig +from exceptions.handle import handle_exception +from module_admin.controller.login_controller import login_controller +from module_admin.service.login_service import LoginService +from utils.jwt_util import JwtUtil +from utils.response_util import ResponseUtil +from utils.time_util import TimezoneUtil + +_USER_ID = '7' +_SESSION_ID = 'logout-session' + + +def _token(*, expired: bool = False, session_id: str = _SESSION_ID) -> str: + return JwtUtil.encode( + { + 'user_id': _USER_ID, + 'session_id': session_id, + 'exp': int(TimezoneUtil.utc_now().timestamp()) + (-60 if expired else 3600), + } + ) + + +@pytest.fixture +def logout_app(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + app = FastAPI() + app.state.redis = object() + app.include_router(login_controller) + handle_exception(app) + logout = AsyncMock(return_value=True) + clear_cache = AsyncMock(return_value=0) + monkeypatch.setattr(LoginService, 'logout_services', logout) + monkeypatch.setattr(ApiCacheManager, 'clear_namespaces', clear_cache) + return SimpleNamespace(app=app, logout=logout, clear_cache=clear_cache) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [False, True]) +@pytest.mark.parametrize('expired', [False, True]) +@pytest.mark.parametrize('scheme', ['Bearer', 'bearer']) +async def test_logout_passes_full_token_and_selected_session_to_service( + logout_app: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + same_time_login: bool, + expired: bool, + scheme: str, +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + token = _token(expired=expired) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=logout_app.app), base_url='http://test') as client: + response = await client.post('/logout', headers={'Authorization': f'{scheme} {token}'}) + + assert response.status_code == status.HTTP_200_OK + assert response.json()['code'] == status.HTTP_200_OK + assert response.json()['msg'] == '退出成功' + logout_app.logout.assert_awaited_once() + request, token_id, expected_token = logout_app.logout.await_args.args + assert isinstance(request, Request) + assert request.app is logout_app.app + assert token_id == (_SESSION_ID if same_time_login else _USER_ID) + assert expected_token == token + logout_app.clear_cache.assert_awaited_once_with(logout_app.app.state.redis, ApiGroup.LOGOUT_MUTATION) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('authorization', [None, 'Basic credentials', 'Bearer invalid-token']) +async def test_logout_rejects_invalid_credentials_before_session_or_cache_changes( + logout_app: SimpleNamespace, authorization: str | None +) -> None: + headers = {'Authorization': authorization} if authorization else {} + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=logout_app.app), base_url='http://test') as client: + response = await client.post('/logout', headers=headers) + + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + logout_app.logout.assert_not_awaited() + logout_app.clear_cache.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [False, True]) +async def test_concurrent_logout_requests_keep_their_own_tokens( + logout_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + tokens = [_token(session_id='old-session'), _token(session_id='new-session')] + entered, resume = asyncio.Event(), asyncio.Event() + arguments = [] + + async def logout(request: Request, token_id: str, token: str) -> bool: + arguments.append((token_id, token)) + if len(arguments) == len(tokens): + entered.set() + await resume.wait() + return True + + logout_app.logout.side_effect = logout + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=logout_app.app), base_url='http://test') as client: + pending = asyncio.gather( + *(client.post('/logout', headers={'Authorization': f'Bearer {token}'}) for token in tokens) + ) + await entered.wait() + resume.set() + responses = await pending + + expected_ids = ['old-session', 'new-session'] if same_time_login else [_USER_ID, _USER_ID] + assert set(arguments) == set(zip(expected_ids, tokens, strict=True)) + assert all(response.json()['msg'] == '退出成功' for response in responses) + assert logout_app.clear_cache.await_count == len(tokens) + + +@pytest.mark.asyncio +async def test_logout_service_failure_does_not_report_success_or_clear_cache( + logout_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + logout_app.logout.side_effect = RedisTimeoutError('redis timeout') + success = Mock(wraps=ResponseUtil.success) + monkeypatch.setattr(ResponseUtil, 'success', success) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=logout_app.app), base_url='http://test') as client: + with pytest.raises(RedisTimeoutError): + await client.post('/logout', headers={'Authorization': f'Bearer {_token()}'}) + + success.assert_not_called() + logout_app.clear_cache.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancelled_logout_does_not_report_success_or_clear_cache( + logout_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + entered, resume = asyncio.Event(), asyncio.Event() + + async def logout(request: Request, token_id: str, token: str) -> bool: + entered.set() + await resume.wait() + return True + + logout_app.logout.side_effect = logout + success = Mock(wraps=ResponseUtil.success) + monkeypatch.setattr(ResponseUtil, 'success', success) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=logout_app.app), base_url='http://test') as client: + pending = asyncio.create_task(client.post('/logout', headers={'Authorization': f'Bearer {_token()}'})) + await entered.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + success.assert_not_called() + logout_app.clear_cache.assert_not_awaited() diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_login_session_renewal.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_login_session_renewal.py new file mode 100644 index 000000000..d6b47b706 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_login_session_renewal.py @@ -0,0 +1,560 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from redis.exceptions import TimeoutError as RedisTimeoutError + +from common.context import RequestContext +from config.env import AppConfig, JwtConfig +from exceptions.exception import AuthException +from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel +from module_admin.service.login_service import _SESSION_LOGOUT_SCRIPT, _SESSION_RENEW_SCRIPT, LoginService +from utils.jwt_util import JwtUtil +from utils.time_util import TimezoneUtil + +_SESSION_TTL = 10 +_REPLACEMENT_TTL = 30 +_PLUGIN_SESSION_READS = 2 + + +class SessionRedis: + """使用可控时间模拟登录会话和原子续期、退出命令。""" + + def __init__(self) -> None: + self.now = 0 + self.values: dict[str, str] = {} + self.deadlines: dict[str, int] = {} + self.renew_calls: list[tuple[str, str, int]] = [] + self.logout_calls: list[tuple[str, str]] = [] + + def _read(self, key: str) -> str | None: + if self.deadlines.get(key, float('inf')) <= self.now: + self.values.pop(key, None) + return self.values.get(key) + + async def get(self, key: str) -> str | None: + return self._read(key) + + async def set(self, key: str, value: str, *, ex: int) -> None: + self.values[key] = value + self.deadlines[key] = self.now + ex + + async def delete(self, key: str) -> None: + self.values.pop(key, None) + self.deadlines.pop(key, None) + + async def eval(self, script: str, count: int, key: str, token: str, seconds: int | None = None) -> int: + assert count == 1 + if script == _SESSION_LOGOUT_SCRIPT: + assert seconds is None + self.logout_calls.append((key, token)) + if self._read(key) != token: + return 0 + self.values.pop(key) + self.deadlines.pop(key, None) + return 1 + assert script == _SESSION_RENEW_SCRIPT + assert seconds is not None + self.renew_calls.append((key, token, seconds)) + if self._read(key) != token: + return 0 + self.deadlines[key] = self.now + seconds + return 1 + + +@pytest.fixture +def session_case(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + redis = SessionRedis() + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(redis=redis))) + token = JwtUtil.encode( + {'user_id': '7', 'session_id': 'session-a', 'exp': int(TimezoneUtil.utc_now().timestamp()) + 3600} + ) + user = CurrentUserModel( + permissions=['demo:read'], roles=['reader'], user=UserInfoModel(userId=7, userName='tester') + ) + get_user = AsyncMock(return_value=user) + monkeypatch.setattr(LoginService, '_get_current_user_from_db', get_user) + RequestContext.clear_all() + return SimpleNamespace(redis=redis, request=request, token=token, user=user, get_user=get_user) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_valid_session_renews_only_its_expiration( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + key = 'access_token:session-a' if same_time_login else 'access_token:7' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + session_case.redis.now = 5 + + result = await LoginService.get_current_user(session_case.request, f'Bearer {session_case.token}', object()) + + assert result is session_case.user + assert await session_case.redis.get(key) == session_case.token + assert session_case.redis.deadlines[key] == session_case.redis.now + JwtConfig.jwt_redis_expire_minutes * 60 + assert session_case.redis.renew_calls == [(key, session_case.token, JwtConfig.jwt_redis_expire_minutes * 60)] + assert RequestContext.get_current_user() is result + RequestContext.clear_all() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +@pytest.mark.parametrize('change', ['logout', 'new_login', 'expired']) +@pytest.mark.parametrize('for_plugin', [True, False]) +async def test_concurrent_session_change_cannot_be_overwritten_or_restored( + session_case: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + same_time_login: bool, + change: str, + for_plugin: bool, +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + key = 'access_token:session-a' if same_time_login else 'access_token:7' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + loaded, resume = asyncio.Event(), asyncio.Event() + + async def load_user(*args: object) -> CurrentUserModel: + loaded.set() + await resume.wait() + return session_case.user + + monkeypatch.setattr(LoginService, '_get_current_user_from_db', load_user) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + resolve_user = LoginService.get_current_user_for_plugin_session if for_plugin else LoginService.get_current_user + pending = asyncio.create_task(resolve_user(session_case.request, session_case.token, object())) + await loaded.wait() + if change == 'logout': + token_id = 'session-a' if same_time_login else '7' + await LoginService.logout_services(session_case.request, token_id, session_case.token) + elif change == 'new_login': + await session_case.redis.set(key, 'new-login-token', ex=_REPLACEMENT_TTL) + else: + session_case.redis.now = _SESSION_TTL + 1 + resume.set() + + with pytest.raises(AuthException) as exc_info: + await pending + assert exc_info.value.message == '用户token已失效,请重新登录' + set_user.assert_not_called() + if for_plugin: + assert session_case.redis.renew_calls == [] + + if change == 'new_login': + assert await session_case.redis.get(key) == 'new-login-token' + assert session_case.redis.deadlines[key] == _REPLACEMENT_TTL + else: + assert await session_case.redis.get(key) is None + + +@pytest.mark.asyncio +async def test_expired_session_does_not_load_user_or_renew( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', True) + key = 'access_token:session-a' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + session_case.redis.now = _SESSION_TTL + 1 + + with pytest.raises(AuthException) as exc_info: + await LoginService.get_current_user(session_case.request, session_case.token, object()) + assert exc_info.value.message == '用户token已失效,请重新登录' + + session_case.get_user.assert_not_awaited() + assert session_case.redis.renew_calls == [] + + +@pytest.mark.asyncio +async def test_renewal_failure_does_not_authenticate_request( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', True) + key = 'access_token:session-a' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + monkeypatch.setattr(session_case.redis, 'eval', AsyncMock(side_effect=RedisTimeoutError('redis timeout'))) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + + with pytest.raises(RedisTimeoutError): + await LoginService.get_current_user(session_case.request, session_case.token, object()) + + set_user.assert_not_called() + assert session_case.redis.deadlines[key] == _SESSION_TTL + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_committed_renewal_timeout_does_not_authenticate_request( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + key = 'access_token:session-a' if same_time_login else 'access_token:7' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + session_case.redis.now = 5 + original_eval = session_case.redis.eval + + async def renew_then_timeout(script: str, count: int, cache_key: str, token: str, seconds: int) -> int: + await original_eval(script, count, cache_key, token, seconds) + raise RedisTimeoutError('redis response timeout') + + monkeypatch.setattr(session_case.redis, 'eval', renew_then_timeout) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + + with pytest.raises(RedisTimeoutError): + await LoginService.get_current_user(session_case.request, session_case.token, object()) + + set_user.assert_not_called() + assert session_case.redis.renew_calls == [(key, session_case.token, JwtConfig.jwt_redis_expire_minutes * 60)] + assert session_case.redis.deadlines[key] == session_case.redis.now + JwtConfig.jwt_redis_expire_minutes * 60 + assert await session_case.redis.get(key) == session_case.token + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_cancelled_committed_renewal_does_not_authenticate_request( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + key = 'access_token:session-a' if same_time_login else 'access_token:7' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + session_case.redis.now = 5 + original_eval = session_case.redis.eval + renewed, resume = asyncio.Event(), asyncio.Event() + + async def renew_then_wait(script: str, count: int, cache_key: str, token: str, seconds: int) -> int: + result = await original_eval(script, count, cache_key, token, seconds) + renewed.set() + await resume.wait() + return result + + monkeypatch.setattr(session_case.redis, 'eval', renew_then_wait) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + pending = asyncio.create_task(LoginService.get_current_user(session_case.request, session_case.token, object())) + await renewed.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + set_user.assert_not_called() + assert session_case.redis.renew_calls == [(key, session_case.token, JwtConfig.jwt_redis_expire_minutes * 60)] + assert session_case.redis.deadlines[key] == session_case.redis.now + JwtConfig.jwt_redis_expire_minutes * 60 + assert await session_case.redis.get(key) == session_case.token + + +@pytest.mark.asyncio +async def test_concurrent_requests_can_renew_the_same_session( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', True) + key = 'access_token:session-a' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + requests = 5 + + results = await asyncio.gather( + *(LoginService.get_current_user(session_case.request, session_case.token, object()) for _ in range(requests)) + ) + + assert len(results) == requests + assert len(session_case.redis.renew_calls) == requests + assert await session_case.redis.get(key) == session_case.token + + +@pytest.mark.asyncio +async def test_cancelled_request_does_not_renew_session( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', True) + key = 'access_token:session-a' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + loaded, resume = asyncio.Event(), asyncio.Event() + + async def load_user(*args: object) -> CurrentUserModel: + loaded.set() + await resume.wait() + return session_case.user + + monkeypatch.setattr(LoginService, '_get_current_user_from_db', load_user) + pending = asyncio.create_task(LoginService.get_current_user(session_case.request, session_case.token, object())) + await loaded.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + assert session_case.redis.renew_calls == [] + assert session_case.redis.deadlines[key] == _SESSION_TTL + assert await session_case.redis.get(key) == session_case.token + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_plugin_validation_never_renews_main_session( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + key = 'access_token:session-a' if same_time_login else 'access_token:7' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + + result = await LoginService.get_current_user_for_plugin_session(session_case.request, session_case.token, object()) + + assert result is session_case.user + assert session_case.redis.renew_calls == [] + assert session_case.redis.deadlines[key] == _SESSION_TTL + RequestContext.clear_all() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_plugin_final_session_check_failure_does_not_authenticate( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + key = 'access_token:session-a' if same_time_login else 'access_token:7' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + original_get = session_case.redis.get + session_reads = 0 + + async def read_session(cache_key: str) -> str | None: + nonlocal session_reads + if cache_key == key: + session_reads += 1 + if session_reads == _PLUGIN_SESSION_READS: + raise RedisTimeoutError('redis timeout') + return await original_get(cache_key) + + monkeypatch.setattr(session_case.redis, 'get', read_session) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + + with pytest.raises(RedisTimeoutError): + await LoginService.get_current_user_for_plugin_session(session_case.request, session_case.token, object()) + + assert session_reads == _PLUGIN_SESSION_READS + session_case.get_user.assert_awaited_once() + set_user.assert_not_called() + assert session_case.redis.renew_calls == [] + assert session_case.redis.deadlines[key] == _SESSION_TTL + assert await original_get(key) == session_case.token + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_cancelled_plugin_final_session_check_does_not_authenticate( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + key = 'access_token:session-a' if same_time_login else 'access_token:7' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + original_get = session_case.redis.get + checking, resume = asyncio.Event(), asyncio.Event() + session_reads = 0 + + async def read_session(cache_key: str) -> str | None: + nonlocal session_reads + if cache_key == key: + session_reads += 1 + if session_reads == _PLUGIN_SESSION_READS: + checking.set() + await resume.wait() + return await original_get(cache_key) + + monkeypatch.setattr(session_case.redis, 'get', read_session) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + pending = asyncio.create_task( + LoginService.get_current_user_for_plugin_session(session_case.request, session_case.token, object()) + ) + await checking.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + assert session_reads == _PLUGIN_SESSION_READS + session_case.get_user.assert_awaited_once() + set_user.assert_not_called() + assert session_case.redis.renew_calls == [] + assert session_case.redis.deadlines[key] == _SESSION_TTL + assert await original_get(key) == session_case.token + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_logout_is_idempotent_and_only_removes_its_session( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + token_id = 'session-a' if same_time_login else '7' + key = f'access_token:{token_id}' + other_key = 'access_token:session-b' if same_time_login else 'access_token:8' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + await session_case.redis.set(other_key, 'other-token', ex=_REPLACEMENT_TTL) + delete = AsyncMock(side_effect=AssertionError('退出不能无条件删除会话')) + monkeypatch.setattr(session_case.redis, 'delete', delete) + requests = 3 + + results = await asyncio.gather( + *(LoginService.logout_services(session_case.request, token_id, session_case.token) for _ in range(requests)) + ) + + assert results == [True] * requests + assert await session_case.redis.get(key) is None + assert key not in session_case.redis.deadlines + assert await session_case.redis.get(other_key) == 'other-token' + assert session_case.redis.deadlines[other_key] == _REPLACEMENT_TTL + assert session_case.redis.logout_calls == [(key, session_case.token)] * requests + assert session_case.redis.renew_calls == [] + session_case.get_user.assert_not_awaited() + delete.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +@pytest.mark.parametrize('state', ['missing', 'expired', 'replaced']) +async def test_logout_of_stale_token_keeps_current_session( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool, state: str +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + token_id = 'session-a' if same_time_login else '7' + key = f'access_token:{token_id}' + if state == 'expired': + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + session_case.redis.now = _SESSION_TTL + 1 + elif state == 'replaced': + await session_case.redis.set(key, 'new-login-token', ex=_REPLACEMENT_TTL) + + assert await LoginService.logout_services(session_case.request, token_id, session_case.token) is True + + if state == 'replaced': + assert await session_case.redis.get(key) == 'new-login-token' + assert session_case.redis.deadlines[key] == _REPLACEMENT_TTL + else: + assert await session_case.redis.get(key) is None + assert session_case.redis.renew_calls == [] + session_case.get_user.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +@pytest.mark.parametrize('login_before_logout', [True, False]) +async def test_concurrent_new_login_survives_old_token_logout( + session_case: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + same_time_login: bool, + login_before_logout: bool, +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + token_id = 'session-a' if same_time_login else '7' + key = f'access_token:{token_id}' + new_key = 'access_token:session-b' if same_time_login else key + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + original_eval = session_case.redis.eval + entered, resume = asyncio.Event(), asyncio.Event() + + async def delayed_logout(script: str, count: int, cache_key: str, token: str) -> int: + result = None if login_before_logout else await original_eval(script, count, cache_key, token) + entered.set() + await resume.wait() + return await original_eval(script, count, cache_key, token) if login_before_logout else result + + monkeypatch.setattr(session_case.redis, 'eval', delayed_logout) + pending = asyncio.create_task(LoginService.logout_services(session_case.request, token_id, session_case.token)) + await entered.wait() + await session_case.redis.set(new_key, 'new-login-token', ex=_REPLACEMENT_TTL) + resume.set() + + assert await pending is True + monkeypatch.setattr(session_case.redis, 'eval', original_eval) + assert await LoginService.logout_services(session_case.request, token_id, session_case.token) is True + assert await session_case.redis.get(new_key) == 'new-login-token' + assert session_case.redis.deadlines[new_key] == _REPLACEMENT_TTL + if same_time_login: + assert await session_case.redis.get(key) is None + assert session_case.redis.renew_calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_logout_after_committed_renewal_does_not_restore_session( + session_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + token_id = 'session-a' if same_time_login else '7' + key = f'access_token:{token_id}' + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + original_eval = session_case.redis.eval + renewed, resume = asyncio.Event(), asyncio.Event() + + async def delayed_renewal(script: str, count: int, cache_key: str, token: str, seconds: int | None = None) -> int: + result = await original_eval(script, count, cache_key, token, seconds) + if script == _SESSION_RENEW_SCRIPT: + renewed.set() + await resume.wait() + return result + + monkeypatch.setattr(session_case.redis, 'eval', delayed_renewal) + pending = asyncio.create_task(LoginService.get_current_user(session_case.request, session_case.token, object())) + await renewed.wait() + assert await LoginService.logout_services(session_case.request, token_id, session_case.token) is True + resume.set() + + # 续期已先完成的请求可以结束,但不能恢复随后退出的会话。 + assert await pending is session_case.user + assert await session_case.redis.get(key) is None + assert key not in session_case.redis.deadlines + with pytest.raises(AuthException): + await LoginService.get_current_user(session_case.request, session_case.token, object()) + assert len(session_case.redis.renew_calls) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +@pytest.mark.parametrize('committed', [True, False]) +@pytest.mark.parametrize('failure', ['timeout', 'cancelled']) +async def test_failed_logout_and_retry_cannot_delete_new_login( + session_case: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + same_time_login: bool, + committed: bool, + failure: str, +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + token_id = 'session-a' if same_time_login else '7' + key = f'access_token:{token_id}' + new_key = 'access_token:session-b' if same_time_login else key + await session_case.redis.set(key, session_case.token, ex=_SESSION_TTL) + original_eval = session_case.redis.eval + entered, resume = asyncio.Event(), asyncio.Event() + + async def failed_logout(script: str, count: int, cache_key: str, token: str) -> int: + if committed: + await original_eval(script, count, cache_key, token) + entered.set() + await resume.wait() + raise RedisTimeoutError('redis response timeout') + + monkeypatch.setattr(session_case.redis, 'eval', failed_logout) + delete = AsyncMock(side_effect=AssertionError('退出失败不能降级为无条件删除')) + monkeypatch.setattr(session_case.redis, 'delete', delete) + pending = asyncio.create_task(LoginService.logout_services(session_case.request, token_id, session_case.token)) + await entered.wait() + assert await session_case.redis.get(key) == (None if committed else session_case.token) + await session_case.redis.set(new_key, 'new-login-token', ex=_REPLACEMENT_TTL) + if failure == 'cancelled': + pending.cancel() + else: + resume.set() + expected_exception = asyncio.CancelledError if failure == 'cancelled' else RedisTimeoutError + with pytest.raises(expected_exception): + await pending + + # 响应异常不能撤销已执行的删除;旧令牌重试也不能删除后续新登录。 + monkeypatch.setattr(session_case.redis, 'eval', original_eval) + assert await LoginService.logout_services(session_case.request, token_id, session_case.token) is True + assert await session_case.redis.get(new_key) == 'new-login-token' + assert session_case.redis.deadlines[new_key] == _REPLACEMENT_TTL + assert session_case.redis.renew_calls == [] + session_case.get_user.assert_not_awaited() + delete.assert_not_awaited() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py index a8943e5d3..82b1f2ea1 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py @@ -364,14 +364,14 @@ async def get_with_concurrent_logout(record_key: str) -> str | None: ), ) # 不替换 _resolve_current_user,验证真实 JWT、用户模型和权限构造路径。 - user = await LoginService.get_current_user_for_plugin_session(request, token, object()) - assert user.user.user_id == user_id - assert user.permissions == ['demo:view'] - set_spy.assert_not_awaited() - assert redis.deadlines[key] == deadline if logout_after_read: - assert key not in redis.values with pytest.raises(AuthException): await LoginService.get_current_user_for_plugin_session(request, token, object()) + assert key not in redis.values else: + user = await LoginService.get_current_user_for_plugin_session(request, token, object()) + assert user.user.user_id == user_id + assert user.permissions == ['demo:view'] assert redis.values[key] == token + set_spy.assert_not_awaited() + assert redis.deadlines[key] == deadline From c4d1aea723df1c5a85e3c54a915ec9ce1d29e1a9 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 28 Oct 2026 17:20:44 +0800 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E4=BE=9D=E8=B5=96=E4=B8=8E=E5=85=AC=E5=BC=80=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/aspect/pre_auth.py | 125 +-- ruoyi-fastapi-backend/common/auth_policy.py | 185 ++++ .../module_admin/service/login_service.py | 41 +- .../tests/common/test_auth_policy.py | 282 ++++++ .../tests/common/test_pre_auth.py | 932 ++++++++++++++++++ .../service/test_current_user_resolution.py | 399 ++++++++ .../plugins/core/runtime/test_explicit.py | 68 ++ .../utils/dependency_util.py | 32 +- 8 files changed, 1942 insertions(+), 122 deletions(-) create mode 100644 ruoyi-fastapi-backend/common/auth_policy.py create mode 100644 ruoyi-fastapi-backend/tests/common/test_auth_policy.py create mode 100644 ruoyi-fastapi-backend/tests/common/test_pre_auth.py create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_resolution.py diff --git a/ruoyi-fastapi-backend/common/aspect/pre_auth.py b/ruoyi-fastapi-backend/common/aspect/pre_auth.py index b17a8bbfb..ead8c696a 100644 --- a/ruoyi-fastapi-backend/common/aspect/pre_auth.py +++ b/ruoyi-fastapi-backend/common/aspect/pre_auth.py @@ -1,127 +1,36 @@ -import re -from typing import Literal, TypedDict - from fastapi import Depends, Request, params -from fastapi.security import OAuth2PasswordBearer -from sqlalchemy.ext.asyncio import AsyncSession -from common.aspect.db_session import DBSessionDependency +from common.auth_policy import AuthRule, ExcludeRoute from common.context import RequestContext -from config.env import AppConfig -from exceptions.exception import AuthException +from exceptions.exception import LoginException from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.service.login_service import LoginService -# 定义排除路由的字典结构 -class ExcludeRoute(TypedDict, total=False): - """ - 排除路由的字典结构 - - :param path: 路由路径(必填) - :param methods: HTTP方法列表,空列表表示所有方法(可选,默认为[]) - :param ignore_paths: 需要忽略的特定路径列表,即使匹配通配符也不排除(可选,默认为[]) - """ - - path: str - methods: list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] - ignore_paths: list[str] - - -# 创建OAuth2PasswordBearer对象 -oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/login') - - -class PreAuth: +class PreAuth(AuthRule): """ 登录认证前置校验依赖类 """ - def __init__(self, exclude_routes: list[ExcludeRoute] | None = None) -> None: + async def __call__( + self, + request: Request, + current_user: CurrentUserModel | None = Depends(LoginService.get_current_user), + ) -> CurrentUserModel | None: """ - 初始化登录认证前置校验依赖 - - :param exclude_routes: 需要排除的路由列表,格式为: - [{'path': '/path1', 'methods': ['GET', 'POST']}, {'path': '/path2/{param}', 'methods': ['GET']}] - methods 可以是字符串或列表,空列表表示所有方法 - """ - self.exclude_routes = exclude_routes or [] - # 编译排除路径为正则表达式模式,并存储方法信息 - self.exclude_patterns = [] - - for route in self.exclude_routes: - # 使用TypedDict,确保路由字典包含path字段 - path = route.get('path', '') - methods = route.get('methods', []) - ignore_paths = route.get('ignore_paths', []) - - # 编译路径为正则表达式 - pattern = self._compile_path_pattern(path) - # 存储编译后的模式和方法信息 - self.exclude_patterns.append( - { - 'pattern': pattern, - 'methods': [method.upper() for method in methods], - 'original_path': path, - 'ignore_paths': ignore_paths, - } - ) - - def _compile_path_pattern(self, path: str) -> re.Pattern: - """ - 将FastAPI路径转换为正则表达式模式 - - :param path: FastAPI路径(如 /configKey/{config_key}) - :return: 编译后的正则表达式模式 - """ - # 将FastAPI路径参数转换为正则表达式 - # 例如:/configKey/{config_key} -> /configKey/[^/]+ - pattern_str = re.sub(r'\{[^}]+\}', r'[^/]+', path) - # 添加开始和结束锚点,确保精确匹配 - return re.compile(f'^{pattern_str}$') - - async def __call__(self, request: Request, db: AsyncSession = DBSessionDependency()) -> CurrentUserModel | None: - """ - 执行登录认证校验 + 保存前置认证规则和登录用户上下文 :param request: 当前请求对象 - :param db: 数据库会话 + :param current_user: 登录认证依赖返回的当前用户 :return: 当前用户信息 """ - # 获取当前请求路径和方法 - path = request.url.path - method = request.method.upper() - - # 从配置中获取APP_ROOT_PATH - app_root_path = AppConfig.app_root_path - - # 去掉APP_ROOT_PATH前缀 - if app_root_path and path.startswith(app_root_path): - path = path[len(app_root_path) :] - - # 设置上下文变量 RequestContext.set_current_exclude_patterns(self.exclude_patterns) - - # 检查路径和方法是否匹配排除模式 - for item in self.exclude_patterns: - pattern = item['pattern'] - exclude_methods = item['methods'] - ignore_paths = item['ignore_paths'] - - # 检查当前路径是否在忽略列表中 - if path in ignore_paths: - continue - - # 检查路径是否匹配,并且methods为空列表(匹配所有方法)或者当前方法在允许列表中 - if pattern.match(path) and (not exclude_methods or method in exclude_methods): - # 跳过认证 - return None - - # 否则执行正常认证 - token = request.headers.get('Authorization') - if not token: - raise AuthException(data='', message='用户未登录,请先完成登录') - current_user = await LoginService.get_current_user(request, token, db) + if current_user is not None: + # 仅补充覆盖依赖未设置的上下文,不能用旧依赖结果覆盖重新鉴权后的权限。 + try: + RequestContext.get_current_user() + except LoginException: + RequestContext.set_current_user(current_user) return current_user @@ -131,7 +40,7 @@ def PreAuthDependency(exclude_routes: list[ExcludeRoute] | None = None) -> param :param exclude_routes: 需要排除的路由列表,格式为: [{'path': '/path1', 'methods': ['GET', 'POST']}, {'path': '/path2/{param}', 'methods': ['GET']}] - methods 可以是字符串或列表,空列表表示所有方法 + methods为空列表表示所有方法 :return: 登录认证前置校验依赖 """ return Depends(PreAuth(exclude_routes)) diff --git a/ruoyi-fastapi-backend/common/auth_policy.py b/ruoyi-fastapi-backend/common/auth_policy.py new file mode 100644 index 000000000..828644769 --- /dev/null +++ b/ruoyi-fastapi-backend/common/auth_policy.py @@ -0,0 +1,185 @@ +import re +from collections.abc import Callable, Iterator +from typing import Literal, TypedDict + +from fastapi import Request +from fastapi.dependencies.models import Dependant +from fastapi.dependencies.utils import get_dependant + +from utils.dependency_util import DependencyUtil + + +# 定义排除路由的字典结构 +class ExcludeRoute(TypedDict, total=False): + """ + 排除路由的字典结构 + + :param path: 路由路径(必填) + :param methods: HTTP方法列表,空列表表示所有方法(可选,默认为[]) + :param ignore_paths: 需要忽略的特定路径列表,即使匹配通配符也不排除(可选,默认为[]) + """ + + path: str + methods: list[Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']] + ignore_paths: list[str] + + +class AuthRule: + """ + 登录认证排除规则 + """ + + def __init__(self, exclude_routes: list[ExcludeRoute] | None = None) -> None: + """ + 初始化登录认证排除规则 + + :param exclude_routes: 需要排除的路由列表,格式为: + [{'path': '/path1', 'methods': ['GET', 'POST']}, {'path': '/path2/{param}', 'methods': ['GET']}] + methods为空列表表示所有方法 + """ + self.exclude_routes = exclude_routes or [] + # 编译排除路径为正则表达式模式,并存储方法信息 + self.exclude_patterns = [] + + for route in self.exclude_routes: + # 使用TypedDict,确保路由字典包含path字段 + path = route.get('path', '') + methods = route.get('methods', []) + ignore_paths = route.get('ignore_paths', []) + + # 编译路径为正则表达式 + pattern = self._compile_path_pattern(path) + self.exclude_patterns.append( + { + 'pattern': pattern, + 'methods': [method.upper() for method in methods], + 'original_path': path, + 'ignore_paths': ignore_paths, + } + ) + + def _compile_path_pattern(self, path: str) -> re.Pattern: + """ + 将FastAPI路径转换为正则表达式模式 + + :param path: FastAPI路径(如 /configKey/{config_key}) + :return: 编译后的正则表达式模式 + """ + # 将FastAPI路径参数转换为正则表达式 + # 例如:/configKey/{config_key} -> /configKey/[^/]+ + pattern_str = re.sub(r'\{[^}]+\}', r'[^/]+', path) + # 添加开始和结束锚点,确保精确匹配 + return re.compile(f'^{pattern_str}$') + + def is_excluded(self, request: Request) -> bool: + """ + 判断当前请求是否排除登录认证 + + :param request: 当前请求对象 + :return: 是否排除认证 + """ + path = DependencyUtil.get_request_path(request) + method = request.method.upper() + return self.is_excluded_path(path, method) + + def is_excluded_path(self, path: str, method: str) -> bool: + """ + 判断应用内路径和请求方法是否排除登录认证 + + :param path: 应用内请求路径 + :param method: 请求方法 + :return: 是否排除认证 + """ + # 检查路径和方法是否匹配排除模式 + for item in self.exclude_patterns: + pattern = item['pattern'] + exclude_methods = item['methods'] + ignore_paths = item['ignore_paths'] + + # 检查当前路径是否在忽略列表中 + if path in ignore_paths: + continue + + # 检查路径是否匹配,并且methods为空列表(匹配所有方法)或者当前方法在允许列表中 + if pattern.fullmatch(path) and (not exclude_methods or method in exclude_methods): + return True + + return False + + +class AuthPolicy: + """ + 登录认证依赖判定 + """ + + @classmethod + def iter_auth_dependencies( + cls, + dependant: Dependant, + auth_dependencies: tuple[Callable, ...], + overrides: dict[Callable, Callable] | None = None, + ) -> Iterator[AuthRule | None]: + """ + 遍历实际生效的登录认证声明 + + :param dependant: 路由依赖 + :param auth_dependencies: 需要识别的认证入口和令牌依赖 + :param overrides: 应用覆盖的依赖 + :return: 前置认证规则对象,显式登录认证返回None + """ + overrides = overrides or {} + + def visit(dependency: Dependant, rule: AuthRule | None = None) -> Iterator[AuthRule | None]: + if dependency.call in overrides: + # 按替换函数的实际声明判断,普通依赖覆盖不会使公开接口变为必需登录。 + dependency = get_dependant( + path=dependency.path or '', + call=overrides[dependency.call], + name=dependency.name, + own_oauth_scopes=dependency.own_oauth_scopes, + parent_oauth_scopes=dependency.parent_oauth_scopes, + use_cache=dependency.use_cache, + scope=dependency.scope, + ) + if dependency.call in auth_dependencies: + yield rule + return + if isinstance(dependency.call, AuthRule): + rule = dependency.call + for child in dependency.dependencies: + yield from visit(child, rule) + + for dependency in dependant.dependencies: + yield from visit(dependency) + + @classmethod + def get_auth_mode( + cls, request: Request, auth_dependencies: tuple[Callable, ...] + ) -> Literal['pre_auth', 'current_user', 'excluded']: + """ + 根据路由的依赖声明确定登录认证方式 + + :param request: 当前请求对象 + :param auth_dependencies: 需要识别的认证入口和令牌依赖 + :return: 前置认证、显式认证或排除认证 + """ + route = request.scope.get('route') + # include_router延迟注册时,父路由和插件追加的依赖在实际路由上下文中。 + context = request.scope.get('fastapi', {}).get('effective_route_context') + if context is not None and context.original_route is route: + route = context + dependant = getattr(route, 'dependant', None) + if dependant is None: + return 'current_user' + provider = getattr(route, 'dependency_overrides_provider', None) + overrides = getattr(provider, 'dependency_overrides', {}) + + # 只读取依赖声明;任一分支要求登录时都不能被其他分支的排除规则放行。 + mode = None + for rule in cls.iter_auth_dependencies(dependant, auth_dependencies, overrides): + if rule is None: + return 'current_user' + if not rule.is_excluded(request): + return 'pre_auth' + mode = 'excluded' + return mode or 'current_user' diff --git a/ruoyi-fastapi-backend/module_admin/service/login_service.py b/ruoyi-fastapi-backend/module_admin/service/login_service.py index 8c2d1810d..e25dbfad2 100644 --- a/ruoyi-fastapi-backend/module_admin/service/login_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/login_service.py @@ -3,12 +3,13 @@ from datetime import datetime, timedelta from typing import Any -from fastapi import Depends, Form, Request +from fastapi import Depends, Form, HTTPException, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy import Row from sqlalchemy.ext.asyncio import AsyncSession from common.aspect.db_session import DBSessionDependency +from common.auth_policy import AuthPolicy from common.constant import CommonConstant, MenuConstant from common.context import RequestContext from common.enums import PasswordCharacterType, RedisInitKeyConfig @@ -35,7 +36,33 @@ from utils.pwd_util import PwdUtil from utils.time_util import TimezoneUtil -oauth2_scheme = OAuth2PasswordBearer(tokenUrl='login') + +class PreAuthOAuth2PasswordBearer(OAuth2PasswordBearer): + """ + 支持公开路由排除的登录令牌依赖 + """ + + async def __call__(self, request: Request) -> str | None: + """ + 根据路由认证要求读取登录令牌 + + :param request: 当前请求对象 + :return: 登录令牌,公开路由返回None + """ + mode = AuthPolicy.get_auth_mode(request, (LoginService.get_current_user, oauth2_scheme)) + if mode == 'excluded': + return None + if mode == 'pre_auth' and not request.headers.get('Authorization'): + raise AuthException(data='', message='用户未登录,请先完成登录') + try: + return await super().__call__(request) + except HTTPException as exc: + if mode == 'pre_auth': + raise AuthException(data='', message='用户token已失效,请重新登录') from exc + raise + + +oauth2_scheme = PreAuthOAuth2PasswordBearer(tokenUrl='login', scheme_name='OAuth2PasswordBearer') _SESSION_RENEW_SCRIPT = """ if redis.call('get', KEYS[1]) ~= ARGV[1] then return 0 @@ -153,18 +180,22 @@ async def create_access_token(cls, data: dict, expires_delta: timedelta | None = async def get_current_user( cls, request: Request = Request, - token: str = Depends(oauth2_scheme), + token: str | None = Depends(oauth2_scheme), query_db: AsyncSession = DBSessionDependency(), - ) -> CurrentUserModel: + ) -> CurrentUserModel | None: """ 根据token获取当前用户信息 :param request: Request对象 :param token: 用户token :param query_db: orm对象 - :return: 当前用户信息对象 + :return: 当前用户信息对象,公开路由返回None :raise: 令牌异常AuthException """ + if token is None: + if AuthPolicy.get_auth_mode(request, (cls.get_current_user, oauth2_scheme)) == 'excluded': + return None + raise AuthException(data='', message='用户未登录,请先完成登录') return await cls._resolve_current_user(request, token, query_db, refresh_session=True) @classmethod diff --git a/ruoyi-fastapi-backend/tests/common/test_auth_policy.py b/ruoyi-fastapi-backend/tests/common/test_auth_policy.py new file mode 100644 index 000000000..173cac0bc --- /dev/null +++ b/ruoyi-fastapi-backend/tests/common/test_auth_policy.py @@ -0,0 +1,282 @@ +import ast +import builtins +import subprocess +import sys +from collections.abc import AsyncIterator +from pathlib import Path +from textwrap import dedent +from typing import Annotated +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status + +from common.aspect.db_session import get_db_session_provider +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.auth_policy import AuthPolicy, AuthRule +from common.context import RequestContext +from config.env import AppConfig +from exceptions.handle import handle_exception +from middlewares.context_middleware import add_context_cleanup_middleware +from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel +from module_admin.service.login_service import LoginService + +BACKEND_ROOT = Path(__file__).resolve().parents[2] + + +def _run_cold_import(script: str, *arguments: str) -> None: + """在独立进程中验证导入边界,避免已加载的模块掩盖循环依赖。""" + result = subprocess.run( + [sys.executable, '-c', dedent(script), *arguments], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_auth_policy_import_does_not_load_authentication_consumers() -> None: + _run_cold_import( + """ + import importlib.abc + import sys + + forbidden = ('common.aspect.pre_auth', 'module_admin.service.login_service') + + class RejectConsumers(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if any(fullname == name or fullname.startswith(name + '.') for name in forbidden): + raise AssertionError('public policy imported consumer: ' + fullname) + return None + + sys.meta_path.insert(0, RejectConsumers()) + from common.auth_policy import AuthPolicy, AuthRule, ExcludeRoute + + rule = AuthRule([{'path': '/public/{name}', 'ignore_paths': ['/public/private']}]) + assert rule.is_excluded_path('/public/free', 'GET') + assert not rule.is_excluded_path('/public/private', 'GET') + assert not set(forbidden).intersection(sys.modules) + """ + ) + + +def test_login_service_runs_without_loading_pre_auth() -> None: + _run_cold_import( + """ + import asyncio + import importlib.abc + import sys + + class RejectPreAuth(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == 'common.aspect.pre_auth': + raise AssertionError('login service imported pre_auth') + return None + + sys.meta_path.insert(0, RejectPreAuth()) + from fastapi import Request + from exceptions.exception import AuthException + from module_admin.service.login_service import LoginService, oauth2_scheme + + async def check_authentication(): + request = Request({'type': 'http', 'headers': [(b'authorization', b'Bearer independent')]}) + assert await oauth2_scheme(request) == 'independent' + try: + await LoginService.get_current_user(request, token=None, query_db=object()) + except AuthException as exc: + assert exc.message == '用户未登录,请先完成登录' + else: + raise AssertionError('missing token was accepted') + + asyncio.run(check_authentication()) + assert 'common.aspect.pre_auth' not in sys.modules + """ + ) + + +@pytest.mark.parametrize( + 'modules', + [ + ('common.aspect.pre_auth', 'module_admin.service.login_service'), + ('module_admin.service.login_service', 'common.aspect.pre_auth'), + ], +) +def test_authentication_import_order_preserves_dependency_identity(modules: tuple[str, str]) -> None: + _run_cold_import( + """ + import importlib + import inspect + import sys + + for index, name in enumerate(sys.argv[1:]): + importlib.import_module(name) + if index == 0 and name == 'module_admin.service.login_service': + assert 'common.aspect.pre_auth' not in sys.modules + + from common.aspect.pre_auth import CurrentUserDependency, ExcludeRoute, PreAuth + from common.auth_policy import AuthRule, ExcludeRoute as PublicExcludeRoute + from module_admin.service.login_service import LoginService, oauth2_scheme + + assert issubclass(PreAuth, AuthRule) + assert ExcludeRoute is PublicExcludeRoute + assert CurrentUserDependency().dependency == LoginService.get_current_user + dependency = inspect.signature(PreAuth.__call__).parameters['current_user'].default + assert dependency.dependency == LoginService.get_current_user + token = inspect.signature(LoginService.get_current_user).parameters['token'].default + assert token.dependency is oauth2_scheme + """, + *modules, + ) + + +@pytest.mark.parametrize( + 'relative_path,forbidden', + [ + ('common/auth_policy.py', {'common.aspect.pre_auth', 'module_admin.service.login_service'}), + ('module_admin/service/login_service.py', {'common.aspect.pre_auth'}), + ], +) +def test_authentication_import_direction_has_no_local_back_imports(relative_path: str, forbidden: set[str]) -> None: + tree = ast.parse((BACKEND_ROOT / relative_path).read_text(encoding='utf-8')) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module) + imported.update(f'{node.module}.{alias.name}' for alias in node.names) + assert not imported.intersection(forbidden) + + +@pytest.mark.asyncio +async def test_registered_authentication_runs_without_importing_pre_auth_again(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', '') + app = FastAPI() + handle_exception(app) + add_context_cleanup_middleware(app) + + async def database() -> AsyncIterator[object]: + yield object() + + user = CurrentUserModel( + permissions=['demo:read'], roles=['reader'], user=UserInfoModel(userId=2, userName='reader') + ) + resolver = AsyncMock(return_value=user) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + app.dependency_overrides[get_db_session_provider(None)] = database + + @app.get('/public', dependencies=[PreAuthDependency([{'path': '/public'}])]) + async def public() -> dict: + return {'public': True} + + @app.get('/protected', dependencies=[PreAuthDependency()]) + async def protected(current_user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> dict: + return {'userId': current_user.user.user_id} + + original_import = builtins.__import__ + + def import_without_pre_auth(name: str, *args: object, **kwargs: object) -> object: + if name == 'common.aspect.pre_auth': + raise AssertionError('登录服务不应在执行时反向导入前置依赖') + return original_import(name, *args, **kwargs) + + try: + with monkeypatch.context() as import_patch: + import_patch.setattr(builtins, '__import__', import_without_pre_auth) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + public_response = await client.get('/public', headers={'Authorization': 'Bearer invalid'}) + assert public_response.json() == {'public': True} + resolver.assert_not_awaited() + denied = await client.get('/protected') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + accepted = await client.get('/protected', headers={'Authorization': 'Bearer valid'}) + assert accepted.json() == {'userId': 2} + resolver.assert_awaited_once() + finally: + RequestContext.clear_all() + + +@pytest.mark.asyncio +async def test_auth_policy_accepts_independent_authentication_dependencies() -> None: + calls = [] + + async def authenticate(request: Request) -> str | None: + mode = AuthPolicy.get_auth_mode(request, (authenticate,)) + calls.append(mode) + if mode == 'excluded': + return None + if request.headers.get('Authorization') != 'Bearer independent': + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return 'independent' + + class IndependentPreAuth(AuthRule): + async def __call__(self, user: Annotated[str | None, Depends(authenticate)]) -> str | None: + return user + + app = FastAPI() + rule = IndependentPreAuth([{'path': '/plain/public'}, {'path': '/required/public'}]) + router = APIRouter(dependencies=[Depends(rule)]) + + @router.get('/public') + async def public() -> dict: + return {'public': True} + + app.include_router(router, prefix='/plain') + app.include_router(router, prefix='/required', dependencies=[Depends(authenticate)]) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + public_response = await client.get('/plain/public') + denied = await client.get('/required/public') + accepted = await client.get('/required/public', headers={'Authorization': 'Bearer independent'}) + assert public_response.json() == accepted.json() == {'public': True} + assert denied.status_code == status.HTTP_401_UNAUTHORIZED + assert calls == ['excluded', 'current_user', 'current_user'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('public', [False, True]) +async def test_dependency_override_preserves_function_scope(public: bool, monkeypatch: pytest.MonkeyPatch) -> None: + app = FastAPI() + handle_exception(app) + add_context_cleanup_middleware(app) + app.dependency_overrides[get_db_session_provider(None)] = object + user = CurrentUserModel(permissions=[], roles=[], user=UserInfoModel(userId=2, userName='reader')) + resolver = AsyncMock(return_value=user) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + events = [] + + async def original() -> str: + return 'original' + + async def resource() -> AsyncIterator[str]: + events.append('open') + try: + yield 'resource' + finally: + events.append('close') + + async def replacement(value: Annotated[str, Depends(resource, scope='function')]) -> AsyncIterator[str]: + try: + yield value + finally: + events.append('release') + + @app.get('/scope', dependencies=[PreAuthDependency([{'path': '/scope'}] if public else [])]) + async def endpoint(value: Annotated[str, Depends(original, scope='function')]) -> dict: + events.append('endpoint') + return {'value': value} + + app.dependency_overrides[original] = replacement + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + if not public: + denied = await client.get('/scope') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert events == [] + response = await client.get('/scope', headers={} if public else {'Authorization': 'Bearer valid'}) + + assert response.json() == {'value': 'resource'} + assert events == ['open', 'endpoint', 'release', 'close'] + assert resolver.await_count == (0 if public else 1) diff --git a/ruoyi-fastapi-backend/tests/common/test_pre_auth.py b/ruoyi-fastapi-backend/tests/common/test_pre_auth.py new file mode 100644 index 000000000..ed5e522d6 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/common/test_pre_auth.py @@ -0,0 +1,932 @@ +import asyncio +from collections.abc import AsyncIterator, Iterator +from types import SimpleNamespace +from typing import Annotated +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import APIRouter, Depends, FastAPI, Request, status +from sqlalchemy import ColumnElement + +from common.aspect.data_scope import DataScopeDependency +from common.aspect.db_session import get_db_session_provider +from common.aspect.interface_auth import RoleInterfaceAuthDependency, UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.context import RequestContext +from config.env import AppConfig +from exceptions.exception import AuthException +from exceptions.handle import handle_exception +from middlewares.context_middleware import add_context_cleanup_middleware +from module_admin.entity.do.user_do import SysUser +from module_admin.entity.vo.role_vo import RoleModel +from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel +from module_admin.service.login_service import LoginService, oauth2_scheme +from utils.dependency_util import DependencyUtil + +USER_IDS = {'alice': 1, 'bob': 2} +TWO_AUTH_CALLS = 2 +THREE_AUTH_CALLS = 3 + + +def _user(user_id: int) -> CurrentUserModel: + return CurrentUserModel( + permissions=[f'user:{user_id}:read'], + roles=['reader'], + user=UserInfoModel( + userId=user_id, + userName=f'user-{user_id}', + role=[RoleModel(roleId=2, roleKey='reader', dataScope='5', status='0', delFlag='0')], + ), + ) + + +@pytest.fixture +def auth_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[SimpleNamespace]: + monkeypatch.setattr(AppConfig, 'app_root_path', '') + app = FastAPI() + handle_exception(app) + add_context_cleanup_middleware(app) + + async def database() -> AsyncIterator[object]: + yield object() + + async def authenticate(request: Request, token: str, db: object, *, refresh_session: bool) -> CurrentUserModel: + assert refresh_session + normalized = token.removeprefix('Bearer ').strip() if token.startswith('Bearer ') else token + if normalized not in USER_IDS: + raise AuthException(message='用户token已失效,请重新登录') + # 交错多个请求,验证请求状态和上下文没有跨用户复用。 + await asyncio.sleep(0) + current_user = _user(USER_IDS[normalized]) + RequestContext.set_current_user(current_user) + return current_user + + resolver = AsyncMock(side_effect=authenticate) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + app.dependency_overrides[get_db_session_provider(None)] = database + + @app.get('/both', dependencies=[PreAuthDependency()]) + async def both( + user: Annotated[CurrentUserModel, CurrentUserDependency()], + same_user: Annotated[CurrentUserModel, CurrentUserDependency()], + ) -> dict: + await asyncio.sleep(0) + return { + 'userId': user.user.user_id, + 'contextUserId': RequestContext.get_current_user().user.user_id, + 'sameUser': user is same_user, + 'permissions': user.permissions, + } + + @app.get('/current') + async def current(user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> dict: + return {'userId': user.user.user_id} + + @app.get('/direct', dependencies=[PreAuthDependency()]) + async def direct( + user: Annotated[CurrentUserModel, CurrentUserDependency()], + direct_user: Annotated[CurrentUserModel, Depends(LoginService.get_current_user)], + ) -> dict: + return {'userId': user.user.user_id, 'sameUser': user is direct_user} + + @app.get('/pre', dependencies=[PreAuthDependency()]) + async def pre() -> dict: + return {'userId': RequestContext.get_current_user().user.user_id} + + excluded = PreAuthDependency([{'path': '/public/{name}', 'methods': ['GET'], 'ignore_paths': ['/public/private']}]) + + @app.api_route('/public/{name}', methods=['GET', 'POST'], dependencies=[excluded]) + async def public(name: str) -> dict: + return {'name': name} + + @app.get('/excluded-current', dependencies=[PreAuthDependency([{'path': '/excluded-current'}])]) + async def excluded_current(user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> dict: + return {'userId': user.user.user_id} + + try: + yield SimpleNamespace(app=app, resolver=resolver) + finally: + RequestContext.clear_all() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('path', ['/both', '/current', '/pre', '/direct']) +async def test_request_authentication_runs_once(auth_app: SimpleNamespace, path: str) -> None: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get(path, headers={'Authorization': 'Bearer alice'}) + assert response.status_code == status.HTTP_200_OK + assert response.json()['userId'] == USER_IDS['alice'] + auth_app.resolver.assert_awaited_once() + if path == '/both': + assert response.json()['sameUser'] + assert response.json()['contextUserId'] == USER_IDS['alice'] + + +@pytest.mark.asyncio +async def test_new_requests_reauthenticate_even_with_the_same_token(auth_app: SimpleNamespace) -> None: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + for _ in range(2): + response = await client.get('/both', headers={'Authorization': 'Bearer alice'}) + assert response.status_code == status.HTTP_200_OK + assert auth_app.resolver.await_count == TWO_AUTH_CALLS + first, second = auth_app.resolver.await_args_list + assert first.args[0] is not second.args[0] + + +@pytest.mark.asyncio +async def test_parallel_requests_keep_user_and_context_separate(auth_app: SimpleNamespace) -> None: + tokens = ['alice', 'bob', 'alice', 'bob'] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + responses = await asyncio.gather( + *(client.get('/both', headers={'Authorization': f'Bearer {token}'}) for token in tokens) + ) + assert auth_app.resolver.await_count == len(tokens) + for token, response in zip(tokens, responses, strict=True): + body = response.json() + assert body['userId'] == body['contextUserId'] == USER_IDS[token] + assert body['permissions'] == [f'user:{USER_IDS[token]}:read'] + assert body['sameUser'] + + +@pytest.mark.asyncio +async def test_excluded_route_keeps_path_method_and_ignore_rules(auth_app: SimpleNamespace) -> None: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/public/free') + assert response.json() == {'name': 'free'} + for method, path in [('POST', '/public/free'), ('GET', '/public/private')]: + response = await client.request(method, path) + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert response.json()['msg'] == '用户未登录,请先完成登录' + response = await client.get('/excluded-current') + assert response.status_code == status.HTTP_401_UNAUTHORIZED + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('path', ['/both', '/current', '/pre']) +async def test_missing_token_preserves_existing_auth_response(auth_app: SimpleNamespace, path: str) -> None: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get(path) + expected_status = status.HTTP_401_UNAUTHORIZED if path == '/current' else status.HTTP_200_OK + assert response.status_code == expected_status + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + if path == '/current': + assert response.headers['www-authenticate'] == 'Bearer' + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('authorization', ['Bearer invalid', 'Basic alice', 'Bearer']) +@pytest.mark.parametrize('path', ['/both', '/current', '/pre']) +async def test_invalid_token_and_header_cannot_reuse_authentication( + auth_app: SimpleNamespace, authorization: str, path: str +) -> None: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + valid = await client.get('/both', headers={'Authorization': 'Bearer alice'}) + assert valid.json()['userId'] == USER_IDS['alice'] + denied = await client.get(path, headers={'Authorization': authorization}) + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +def test_current_user_dependency_keeps_original_oauth_token_url(auth_app: SimpleNamespace) -> None: + security = auth_app.app.openapi()['components']['securitySchemes']['OAuth2PasswordBearer'] + assert security['flows']['password']['tokenUrl'] == 'login' + + +@pytest.mark.asyncio +async def test_direct_calls_authenticate_each_time(monkeypatch: pytest.MonkeyPatch) -> None: + request = Request({'type': 'http', 'headers': []}) + database = object() + resolver = AsyncMock(side_effect=[_user(USER_IDS['alice']), _user(USER_IDS['alice'])]) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + first = await LoginService.get_current_user(request, 'alice', database) + second = await LoginService.get_current_user(request, 'alice', database) + assert second is not first + assert resolver.await_count == TWO_AUTH_CALLS + assert not hasattr(request.state, '_current_user_auth') + + +@pytest.mark.asyncio +async def test_failed_authentication_is_not_saved(monkeypatch: pytest.MonkeyPatch) -> None: + request = Request({'type': 'http', 'headers': []}) + database = object() + user = _user(USER_IDS['alice']) + resolver = AsyncMock(side_effect=[AuthException(message='认证失败'), user]) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + with pytest.raises(AuthException): + await LoginService.get_current_user(request, 'alice', database) + assert not hasattr(request.state, '_current_user_auth') + assert await LoginService.get_current_user(request, 'alice', database) is user + assert resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +async def test_cancelled_authentication_is_not_saved(monkeypatch: pytest.MonkeyPatch) -> None: + request = Request({'type': 'http', 'headers': []}) + database = object() + entered = asyncio.Event() + + async def blocked(*args: object, **kwargs: object) -> CurrentUserModel: + entered.set() + await asyncio.Event().wait() + raise AssertionError('cancelled request resumed') + + resolver = AsyncMock(side_effect=blocked) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + task = asyncio.create_task(LoginService.get_current_user(request, 'alice', database)) + await asyncio.wait_for(entered.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not hasattr(request.state, '_current_user_auth') + user = _user(USER_IDS['alice']) + resolver.side_effect = None + resolver.return_value = user + assert await LoginService.get_current_user(request, 'alice', database) is user + assert resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +async def test_excluded_route_matches_configured_root_path( + auth_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', '/dev-api') + transport = httpx.ASGITransport(app=auth_app.app, root_path='/dev-api') + async with httpx.AsyncClient(transport=transport, base_url='http://test') as client: + response = await client.get('/dev-api/public/free', headers={'Authorization': 'Bearer invalid'}) + assert response.json() == {'name': 'free'} + response = await client.get('/dev-api/public/private') + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_original_dependency_override_applies_to_both_declarations(auth_app: SimpleNamespace) -> None: + user = _user(USER_IDS['bob']) + override_calls = [] + + async def override() -> CurrentUserModel: + override_calls.append(user) + return user + + @auth_app.app.get( + '/overridden', + dependencies=[PreAuthDependency(), UserInterfaceAuthDependency(f'user:{USER_IDS["bob"]}:read')], + ) + async def overridden( + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + direct_user: Annotated[CurrentUserModel, Depends(LoginService.get_current_user)], + ) -> dict: + return { + 'userId': current_user.user.user_id, + 'contextUserId': RequestContext.get_current_user().user.user_id, + 'sameUser': current_user is direct_user, + } + + auth_app.app.dependency_overrides[LoginService.get_current_user] = override + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/overridden') + assert response.status_code == status.HTTP_200_OK + assert response.json() == {'userId': USER_IDS['bob'], 'contextUserId': USER_IDS['bob'], 'sameUser': True} + assert override_calls == [user] + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_calls_without_request_state_authenticate_each_time(monkeypatch: pytest.MonkeyPatch) -> None: + request = SimpleNamespace() + database = object() + resolver = AsyncMock(return_value=_user(USER_IDS['alice'])) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + await LoginService.get_current_user(request, 'alice', database) + await LoginService.get_current_user(request, 'alice', database) + assert resolver.await_count == TWO_AUTH_CALLS + assert not hasattr(request, 'state') + + +@pytest.mark.asyncio +async def test_plugin_session_validation_never_reuses_main_request_result(monkeypatch: pytest.MonkeyPatch) -> None: + request = Request({'type': 'http', 'headers': []}) + database = object() + resolver = AsyncMock(return_value=_user(USER_IDS['alice'])) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + await LoginService.get_current_user(request, 'alice', database) + await LoginService.get_current_user_for_plugin_session(request, 'alice', database) + await LoginService.get_current_user_for_plugin_session(request, 'alice', database) + assert resolver.await_count == THREE_AUTH_CALLS + assert [call.kwargs['refresh_session'] for call in resolver.await_args_list] == [True, False, False] + + +@pytest.mark.asyncio +async def test_non_string_token_keeps_auth_exception_conversion() -> None: + request = Request({'type': 'http', 'headers': []}) + with pytest.raises(AuthException) as exc_info: + await LoginService.get_current_user(request, b'invalid', object()) + assert exc_info.value.message == '用户token已失效,请重新登录' + assert not hasattr(request.state, '_current_user_auth') + + +@pytest.mark.asyncio +async def test_use_cache_false_reauthenticates_after_pre_auth(auth_app: SimpleNamespace) -> None: + @auth_app.app.get('/fresh', dependencies=[PreAuthDependency()]) + async def fresh( + first: Annotated[CurrentUserModel, CurrentUserDependency()], + current: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + ) -> dict: + return { + 'sameUser': first is current, + 'userId': current.user.user_id, + 'currentContext': RequestContext.get_current_user() is current, + } + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/fresh', headers={'Authorization': 'Bearer alice'}) + assert response.json() == {'sameUser': False, 'userId': USER_IDS['alice'], 'currentContext': True} + assert auth_app.resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +async def test_use_cache_false_does_not_fall_back_to_previous_success(auth_app: SimpleNamespace) -> None: + @auth_app.app.get('/fresh-denied', dependencies=[PreAuthDependency()]) + async def fresh_denied( + current: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + ) -> dict: + raise AssertionError('failed authentication was ignored') + + auth_app.resolver.side_effect = [_user(USER_IDS['alice']), AuthException(message='令牌已撤销')] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/fresh-denied', headers={'Authorization': 'Bearer alice'}) + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert response.json()['msg'] == '令牌已撤销' + assert auth_app.resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +async def test_later_pre_auth_cannot_restore_permissions_before_forced_authentication( + auth_app: SimpleNamespace, +) -> None: + initial_user = _user(USER_IDS['bob']) + refreshed_user = _user(USER_IDS['bob']) + refreshed_user.permissions = [] + users = iter([initial_user, refreshed_user]) + observed_users = [] + + async def authenticate(request: Request, token: str, db: object, *, refresh_session: bool) -> CurrentUserModel: + user = next(users) + RequestContext.set_current_user(user) + return user + + async def observe_context() -> None: + observed_users.append(RequestContext.get_current_user()) + + auth_app.resolver.side_effect = authenticate + + @auth_app.app.get('/revoked-permissions', dependencies=[PreAuthDependency()]) + async def revoked_permissions( + fresh: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + another: Annotated[CurrentUserModel, PreAuthDependency()], + observed: Annotated[None, Depends(observe_context)], + permitted: Annotated[bool, UserInterfaceAuthDependency(f'user:{USER_IDS["bob"]}:read')], + ) -> dict: + raise AssertionError('revoked permission was restored from the dependency cache') + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/revoked-permissions', headers={'Authorization': 'Bearer bob'}) + assert response.json()['code'] == status.HTTP_403_FORBIDDEN + assert observed_users == [refreshed_user] + assert observed_users[0] is refreshed_user + assert auth_app.resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +@pytest.mark.parametrize('authorization', [None, 'Bearer alice', 'Bearer invalid', 'Basic invalid']) +async def test_public_route_never_authenticates(auth_app: SimpleNamespace, authorization: str | None) -> None: + headers = {'Authorization': authorization} if authorization is not None else {} + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/public/free', headers=headers) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {'name': 'free'} + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_explicit_current_user_still_authenticates_on_excluded_route(auth_app: SimpleNamespace) -> None: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/excluded-current', headers={'Authorization': 'Bearer bob'}) + assert response.json() == {'userId': USER_IDS['bob']} + denied = await client.get('/excluded-current', headers={'Authorization': 'Bearer invalid'}) + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert auth_app.resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +async def test_nested_current_user_dependency_requires_login_on_excluded_route(auth_app: SimpleNamespace) -> None: + async def identity(user: Annotated[CurrentUserModel, Depends(LoginService.get_current_user)]) -> int: + return user.user.user_id + + @auth_app.app.get('/nested', dependencies=[PreAuthDependency([{'path': '/nested'}])]) + async def nested(user_id: Annotated[int, Depends(identity)]) -> dict: + return {'userId': user_id} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + denied = await client.get('/nested') + assert denied.status_code == status.HTTP_401_UNAUTHORIZED + accepted = await client.get('/nested', headers={'Authorization': 'Bearer bob'}) + assert accepted.json() == {'userId': USER_IDS['bob']} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_override_cannot_add_authentication_to_an_excluded_dependency_without_login( + auth_app: SimpleNamespace, +) -> None: + async def identity() -> int: + return 0 + + async def authenticated_identity(user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> int: + return user.user.user_id + + @auth_app.app.get('/override-nested', dependencies=[PreAuthDependency([{'path': '/override-nested'}])]) + async def nested(user_id: Annotated[int, Depends(identity)]) -> dict: + return {'userId': user_id} + + auth_app.app.dependency_overrides[identity] = authenticated_identity + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + denied = await client.get('/override-nested') + assert denied.status_code == status.HTTP_401_UNAUTHORIZED + accepted = await client.get('/override-nested', headers={'Authorization': 'Bearer bob'}) + assert accepted.json() == {'userId': USER_IDS['bob']} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_direct_call_without_a_token_requires_login() -> None: + request = Request({'type': 'http', 'headers': []}) + with pytest.raises(AuthException) as exc_info: + await LoginService.get_current_user(request, None, object()) + assert exc_info.value.message == '用户未登录,请先完成登录' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('excluded_first', [True, False]) +async def test_excluded_pre_auth_cannot_cancel_another_auth_requirement( + auth_app: SimpleNamespace, excluded_first: bool +) -> None: + dependencies = [PreAuthDependency([{'path': '/mixed'}]), PreAuthDependency()] + if not excluded_first: + dependencies.reverse() + + @auth_app.app.get('/mixed', dependencies=dependencies) + async def mixed() -> dict: + return {'userId': RequestContext.get_current_user().user.user_id} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + denied = await client.get('/mixed') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert denied.json()['msg'] == '用户未登录,请先完成登录' + accepted = await client.get('/mixed', headers={'Authorization': 'Bearer bob'}) + assert accepted.json() == {'userId': USER_IDS['bob']} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_multiple_excluded_pre_auth_dependencies_do_not_require_login(auth_app: SimpleNamespace) -> None: + @auth_app.app.get( + '/all-public', + dependencies=[ + PreAuthDependency([{'path': '/all-public'}]), + PreAuthDependency([{'path': '/all-public', 'methods': ['GET']}]), + ], + ) + async def public() -> dict: + return {'public': True} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/all-public', headers={'Authorization': 'Bearer invalid'}) + assert response.json() == {'public': True} + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('pre_auth_first', [True, False]) +async def test_first_required_dependency_keeps_missing_token_response( + auth_app: SimpleNamespace, pre_auth_first: bool +) -> None: + dependencies = [PreAuthDependency(), CurrentUserDependency()] + if not pre_auth_first: + dependencies.reverse() + + @auth_app.app.get('/ordered', dependencies=dependencies) + async def ordered() -> dict: + return {'ok': True} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/ordered') + expected_status = status.HTTP_200_OK if pre_auth_first else status.HTTP_401_UNAUTHORIZED + assert response.status_code == expected_status + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_failed_request_does_not_reuse_a_previous_authentication(auth_app: SimpleNamespace) -> None: + authenticate = auth_app.resolver.side_effect + auth_app.resolver.side_effect = AuthException(message='认证失败') + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + failed = await client.get('/both', headers={'Authorization': 'Bearer alice'}) + assert failed.json()['code'] == status.HTTP_401_UNAUTHORIZED + auth_app.resolver.side_effect = authenticate + accepted = await client.get('/both', headers={'Authorization': 'Bearer alice'}) + assert accepted.json()['userId'] == USER_IDS['alice'] + assert auth_app.resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +async def test_cancelled_request_does_not_affect_following_authentication(auth_app: SimpleNamespace) -> None: + authenticate = auth_app.resolver.side_effect + entered = asyncio.Event() + + async def blocked(*args: object, **kwargs: object) -> CurrentUserModel: + entered.set() + await asyncio.Event().wait() + raise AssertionError('cancelled request resumed') + + auth_app.resolver.side_effect = blocked + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + task = asyncio.create_task(client.get('/both', headers={'Authorization': 'Bearer alice'})) + await asyncio.wait_for(entered.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + auth_app.resolver.side_effect = authenticate + response = await client.get('/both', headers={'Authorization': 'Bearer bob'}) + assert response.json()['userId'] == response.json()['contextUserId'] == USER_IDS['bob'] + assert auth_app.resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +async def test_interface_role_and_data_permissions_are_still_checked(auth_app: SimpleNamespace) -> None: + @auth_app.app.get( + '/scope', + dependencies=[ + PreAuthDependency(), + UserInterfaceAuthDependency(f'user:{USER_IDS["bob"]}:read'), + RoleInterfaceAuthDependency('reader'), + ], + ) + async def scope( + user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope: Annotated[ColumnElement, DataScopeDependency(SysUser)], + ) -> dict: + return {'userId': user.user.user_id, 'scope': str(data_scope.compile(compile_kwargs={'literal_binds': True}))} + + @auth_app.app.get( + '/forbidden-permission', + dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('system:user:delete')], + ) + @auth_app.app.get( + '/forbidden-role', + dependencies=[PreAuthDependency(), RoleInterfaceAuthDependency('administrator')], + ) + async def forbidden() -> dict: + raise AssertionError('permission check was skipped') + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + accepted = await client.get('/scope', headers={'Authorization': 'Bearer bob'}) + assert accepted.json() == {'userId': USER_IDS['bob'], 'scope': f'sys_user.user_id = {USER_IDS["bob"]}'} + for path in ['/forbidden-permission', '/forbidden-role']: + denied = await client.get(path, headers={'Authorization': 'Bearer bob'}) + assert denied.json()['code'] == status.HTTP_403_FORBIDDEN + assert auth_app.resolver.await_count == THREE_AUTH_CALLS + + +@pytest.mark.asyncio +@pytest.mark.parametrize('path', ['/pre', '/both', '/current']) +@pytest.mark.parametrize('scheme', ['Bearer', 'bearer', 'BEARER']) +async def test_authentication_accepts_standard_bearer_scheme_case_insensitively( + auth_app: SimpleNamespace, path: str, scheme: str +) -> None: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get(path, headers={'Authorization': f'{scheme} alice'}) + assert response.status_code == status.HTTP_200_OK + assert response.json()['userId'] == USER_IDS['alice'] + auth_app.resolver.assert_awaited_once() + assert auth_app.resolver.await_args.args[1] == 'alice' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('path', ['/pre', '/both', '/current']) +@pytest.mark.parametrize('authorization', ['alice', 'Basic alice']) +async def test_authentication_rejects_non_bearer_authorization( + auth_app: SimpleNamespace, path: str, authorization: str +) -> None: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get(path, headers={'Authorization': authorization}) + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + expected_status = status.HTTP_401_UNAUTHORIZED if path == '/current' else status.HTTP_200_OK + assert response.status_code == expected_status + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('excluded', [False, True]) +async def test_explicit_oauth_dependency_returns_bare_token_and_still_requires_login( + auth_app: SimpleNamespace, excluded: bool +) -> None: + exclude_routes = [{'path': '/token'}] if excluded else None + + @auth_app.app.get('/token', dependencies=[PreAuthDependency(exclude_routes)]) + async def token_endpoint(token: Annotated[str, Depends(oauth2_scheme)]) -> dict: + return {'token': token} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + for headers in [{}, {'Authorization': 'alice'}, {'Authorization': 'Basic alice'}]: + denied = await client.get('/token', headers=headers) + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + accepted = await client.get('/token', headers={'Authorization': 'Bearer alice'}) + assert accepted.json() == {'token': 'alice'} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_unrelated_override_keeps_an_excluded_route_public(auth_app: SimpleNamespace) -> None: + async def language() -> str: + return 'en' + + async def translated_language() -> str: + return 'zh' + + router = APIRouter(dependencies=[PreAuthDependency([{'path': '/translated/public'}])]) + + @router.get('/public') + async def public(locale: Annotated[str, Depends(language)]) -> dict: + return {'locale': locale} + + auth_app.app.include_router(router, prefix='/translated') + auth_app.app.dependency_overrides[language] = translated_language + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + response = await client.get('/translated/public') + assert response.status_code == status.HTTP_200_OK + assert response.json() == {'locale': 'zh'} + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('nested', [False, True]) +@pytest.mark.parametrize('requirement', ['current_user', 'pre_auth', 'oauth']) +async def test_parent_router_authentication_cannot_be_excluded_by_child( + auth_app: SimpleNamespace, nested: bool, requirement: str +) -> None: + users = [] + + async def require_user(user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> None: + users.append(user) + + requirements = { + 'current_user': Depends(require_user), + 'pre_auth': PreAuthDependency(), + 'oauth': Depends(oauth2_scheme), + } + path = '/parent/child/profile' if nested else '/parent/profile' + router = APIRouter(dependencies=[PreAuthDependency([{'path': path}])]) + + @router.get('/profile') + async def profile() -> dict: + return {'protected': True} + + if nested: + parent = APIRouter() + parent.include_router(router, prefix='/child') + router = parent + auth_app.app.include_router(router, prefix='/parent', dependencies=[requirements[requirement]]) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + denied = await client.get(path) + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert users == [] + accepted = await client.get(path, headers={'Authorization': 'Bearer alice'}) + assert accepted.status_code == status.HTTP_200_OK + assert accepted.json() == {'protected': True} + auth_app.resolver.assert_awaited_once() + if requirement == 'current_user': + assert [user.user.user_id for user in users] == [USER_IDS['alice']] + + +@pytest.mark.asyncio +async def test_router_inclusions_keep_their_own_authentication_requirements(auth_app: SimpleNamespace) -> None: + async def require_user(user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> None: + assert user.user.user_id == USER_IDS['alice'] + + router = APIRouter(dependencies=[PreAuthDependency([{'path': '/open/info'}, {'path': '/secured/info'}])]) + + @router.get('/info') + async def info() -> dict: + return {'available': True} + + auth_app.app.include_router(router, prefix='/open') + auth_app.app.include_router(router, prefix='/secured', dependencies=[Depends(require_user)]) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + public = await client.get('/open/info') + assert public.json() == {'available': True} + denied = await client.get('/secured/info') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + accepted = await client.get('/secured/info', headers={'Authorization': 'Bearer alice'}) + assert accepted.json() == {'available': True} + public_again = await client.get('/open/info', headers={'Authorization': 'Bearer invalid'}) + assert public_again.json() == {'available': True} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_parent_override_added_authentication_is_required(auth_app: SimpleNamespace) -> None: + async def identity() -> None: + return None + + async def authenticated_identity(user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> None: + assert user.user.user_id == USER_IDS['alice'] + + router = APIRouter(dependencies=[PreAuthDependency([{'path': '/overridden/public'}])]) + + @router.get('/public') + async def public() -> dict: + return {'available': True} + + auth_app.app.include_router(router, prefix='/overridden', dependencies=[Depends(identity)]) + auth_app.app.dependency_overrides[identity] = authenticated_identity + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app.app), base_url='http://test') as client: + denied = await client.get('/overridden/public') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + accepted = await client.get('/overridden/public', headers={'Authorization': 'Bearer alice'}) + assert accepted.json() == {'available': True} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('root_path', ['', '/api']) +async def test_business_prefix_is_not_removed_as_a_deployment_prefix( + auth_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, root_path: str +) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', '/api') + router = APIRouter(prefix='/api', dependencies=[PreAuthDependency([{'path': '/api/public'}, {'path': '/private'}])]) + + @router.get('/public') + @router.get('/private') + async def endpoint() -> dict: + return {'available': True} + + auth_app.app.include_router(router) + transport = httpx.ASGITransport(app=auth_app.app, root_path=root_path) + async with httpx.AsyncClient(transport=transport, base_url='http://test') as client: + public = await client.get(f'{root_path}/api/public', headers={'Authorization': 'Bearer invalid'}) + assert public.json() == {'available': True} + denied = await client.get(f'{root_path}/api/private') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + accepted = await client.get(f'{root_path}/api/private', headers={'Authorization': 'Bearer bob'}) + assert accepted.json() == {'available': True} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('root_path', ['', '/api']) +async def test_deployment_prefix_requires_a_path_segment_boundary( + auth_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, root_path: str +) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', '/api') + + @auth_app.app.get('/api-extra/public', dependencies=[PreAuthDependency([{'path': '/api-extra/public'}])]) + async def public() -> dict: + return {'available': True} + + transport = httpx.ASGITransport(app=auth_app.app, root_path=root_path) + async with httpx.AsyncClient(transport=transport, base_url='http://test') as client: + response = await client.get('/api-extra/public', headers={'Authorization': 'Bearer invalid'}) + assert response.json() == {'available': True} + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('root_path', ['', '/api']) +@pytest.mark.parametrize('permission', ['interface', 'role', 'data_scope']) +async def test_permission_dependencies_use_the_same_excluded_application_path( + auth_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, root_path: str, permission: str +) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', '/api') + permissions = { + 'interface': UserInterfaceAuthDependency(f'user:{USER_IDS["bob"]}:read'), + 'role': RoleInterfaceAuthDependency('reader'), + 'data_scope': DataScopeDependency(SysUser), + } + router = APIRouter( + prefix='/api', + dependencies=[ + PreAuthDependency([{'path': '/api/public-scope'}, {'path': '/private-scope'}]), + permissions[permission], + ], + ) + + @router.get('/public-scope') + @router.get('/private-scope') + async def endpoint() -> dict: + return {'available': True} + + auth_app.app.include_router(router) + transport = httpx.ASGITransport(app=auth_app.app, root_path=root_path) + async with httpx.AsyncClient(transport=transport, base_url='http://test') as client: + excluded = await client.get(f'{root_path}/api/public-scope') + assert excluded.json()['code'] == status.HTTP_403_FORBIDDEN + assert '当前路由不在认证规则内' in excluded.json()['msg'] + auth_app.resolver.assert_not_awaited() + protected = await client.get(f'{root_path}/api/private-scope', headers={'Authorization': 'Bearer bob'}) + assert protected.json() == {'available': True} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('root_path', ['', '/gateway']) +@pytest.mark.parametrize( + 'encoded,decoded', + [('%23private', '#private'), ('%3Fprivate', '?private'), ('%0A', '\n'), ('%0D', '\r'), ('%09', '\t')], +) +@pytest.mark.parametrize('ignore_path', [False, True]) +async def test_encoded_path_separators_cannot_expand_public_routes( + auth_app: SimpleNamespace, root_path: str, encoded: str, decoded: str, ignore_path: bool +) -> None: + protected_path = f'/encoded/public{decoded}' + rule = {'path': '/encoded/{name}', 'ignore_paths': [protected_path]} if ignore_path else {'path': '/encoded/public'} + + @auth_app.app.get('/encoded/{name}', dependencies=[PreAuthDependency([rule])]) + async def endpoint(name: str) -> dict: + return {'name': name} + + transport = httpx.ASGITransport(app=auth_app.app, root_path=root_path) + async with httpx.AsyncClient(transport=transport, base_url='http://test') as client: + public = await client.get(f'{root_path}/encoded/public') + assert public.json() == {'name': 'public'} + denied = await client.get(f'{root_path}/encoded/public{encoded}') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + auth_app.resolver.assert_not_awaited() + accepted = await client.get(f'{root_path}/encoded/public{encoded}', headers={'Authorization': 'Bearer bob'}) + assert accepted.json() == {'name': f'public{decoded}'} + auth_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('root_path', ['', '/gateway']) +@pytest.mark.parametrize( + 'encoded,decoded', + [('%23private', '#private'), ('%3Fprivate', '?private'), ('%0A', '\n'), ('%0D', '\r'), ('%09', '\t')], +) +async def test_permission_check_uses_the_full_decoded_path( + auth_app: SimpleNamespace, root_path: str, encoded: str, decoded: str +) -> None: + @auth_app.app.get( + '/encoded-permission/{name}', + dependencies=[ + PreAuthDependency([{'path': '/encoded-permission/public'}]), + UserInterfaceAuthDependency(f'user:{USER_IDS["bob"]}:read'), + ], + ) + async def endpoint(name: str) -> dict: + return {'name': name} + + transport = httpx.ASGITransport(app=auth_app.app, root_path=root_path) + async with httpx.AsyncClient(transport=transport, base_url='http://test') as client: + denied = await client.get( + f'{root_path}/encoded-permission/public{encoded}', headers={'Authorization': 'Bearer alice'} + ) + assert denied.json()['code'] == status.HTTP_403_FORBIDDEN + assert denied.json()['msg'] == '该用户无此接口权限' + accepted = await client.get( + f'{root_path}/encoded-permission/public{encoded}', headers={'Authorization': 'Bearer bob'} + ) + assert accepted.json() == {'name': f'public{decoded}'} + assert auth_app.resolver.await_count == TWO_AUTH_CALLS + + +@pytest.mark.asyncio +async def test_mounted_application_uses_its_asgi_root_path( + auth_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', '/api') + app = FastAPI() + app.mount('/gateway', auth_app.app) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + public = await client.get('/gateway/public/free', headers={'Authorization': 'Bearer invalid'}) + assert public.json() == {'name': 'free'} + denied = await client.get('/gateway/public/private') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + auth_app.resolver.assert_not_awaited() + + +@pytest.mark.parametrize('path,expected', [('/api/public', '/public'), ('/api-extra/public', '/api-extra/public')]) +@pytest.mark.parametrize('with_scope', [True, False]) +def test_direct_dependency_path_keeps_configured_prefix_fallback( + monkeypatch: pytest.MonkeyPatch, path: str, expected: str, with_scope: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', '/api') + request = ( + Request({'type': 'http', 'path': path, 'headers': []}) + if with_scope + else SimpleNamespace(url=SimpleNamespace(path=path)) + ) + assert DependencyUtil.get_request_path(request) == expected diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_resolution.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_resolution.py new file mode 100644 index 000000000..f29790e6d --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_resolution.py @@ -0,0 +1,399 @@ +import asyncio +from collections.abc import AsyncIterator +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace +from typing import Annotated +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from fastapi import APIRouter, Depends, FastAPI +from httpx import ASGITransport, AsyncClient +from redis.exceptions import ConnectionError as RedisConnectionError +from redis.exceptions import NoPermissionError, RedisError +from redis.exceptions import TimeoutError as RedisTimeoutError +from sqlalchemy import delete, event, update +from sqlalchemy.dialects.mysql import TINYINT +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.compiler import compiles + +from common.aspect.db_session import DBSessionDependency, get_db_session_provider +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import HttpStatusConstant +from common.context import RequestContext +from config.database import Base +from config.env import AppConfig +from exceptions.handle import handle_exception +from middlewares.context_middleware import ContextCleanupMiddleware +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.post_do import SysPost +from module_admin.entity.do.role_do import SysRole, SysRoleMenu +from module_admin.entity.do.user_do import SysUser, SysUserPost, SysUserRole +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.login_service import LoginService +from tests.module_admin.service.test_login_session_renewal import SessionRedis +from utils.jwt_util import JwtUtil +from utils.time_util import TimezoneUtil + +ADMIN_ID = 1 +USER_ID = 2 +REGULAR_QUERY_COUNT = 4 +ADMIN_QUERY_COUNT = 3 +TWO_REQUESTS = 2 +TWO_AUTHENTICATIONS = 2 + + +@compiles(TINYINT, 'sqlite') +def _compile_tinyint(_type: TINYINT, _compiler: object, **kwargs: object) -> str: + return 'SMALLINT' + + +class AuthenticationRedis(SessionRedis): + """仅允许原有登录会话和系统配置访问,禁止新增用户快照缓存。""" + + async def get(self, key: str) -> str | None: + assert key.startswith(('access_token:', 'sys_config:')) + return await super().get(key) + + async def set(self, key: str, value: str, *, ex: int) -> None: + assert key.startswith('access_token:') + await super().set(key, value, ex=ex) + + +@pytest_asyncio.fixture +async def resolution_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[SimpleNamespace]: + monkeypatch.setattr(AppConfig, 'app_same_time_login', True) + redis = AuthenticationRedis() + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "user-resolution.db"}') + sessions = async_sessionmaker(engine, expire_on_commit=False, autoflush=False) + tables = [SysUser, SysDept, SysRole, SysPost, SysMenu, SysUserRole, SysUserPost, SysRoleMenu] + async with engine.begin() as connection: + await connection.run_sync( + lambda conn: Base.metadata.create_all(conn, tables=[model.__table__ for model in tables]) + ) + async with sessions.begin() as db: + db.add_all( + [ + SysUser(user_id=ADMIN_ID, user_name='admin', nick_name='Admin', dept_id=USER_ID), + SysUser( + user_id=USER_ID, + user_name='reader', + nick_name='Reader', + dept_id=USER_ID, + password='private-password-hash', + ), + SysDept(dept_id=USER_ID, dept_name='Engineering', ancestors='0,1'), + SysRole(role_id=ADMIN_ID, role_name='Admin', role_key='admin', role_sort=0, status='0'), + SysRole( + role_id=USER_ID, role_name='Reader', role_key='reader', role_sort=1, status='0', data_scope='4' + ), + SysPost(post_id=USER_ID, post_name='Developer', post_code='dev', post_sort=1, status='0'), + SysMenu(menu_id=USER_ID, menu_name='Read', perms='demo:read', status='0'), + SysMenu(menu_id=USER_ID + 1, menu_name='Disabled', perms='demo:disabled', status='1'), + SysUserRole(user_id=ADMIN_ID, role_id=ADMIN_ID), + SysUserRole(user_id=USER_ID, role_id=USER_ID), + SysUserPost(user_id=ADMIN_ID, post_id=USER_ID), + SysUserPost(user_id=USER_ID, post_id=USER_ID), + SysRoleMenu(role_id=USER_ID, menu_id=USER_ID), + SysRoleMenu(role_id=USER_ID, menu_id=USER_ID + 1), + ] + ) + statements = [] + + @event.listens_for(engine.sync_engine, 'before_cursor_execute') + def record_select(connection: object, cursor: object, statement: str, *args: object) -> None: + if statement.lstrip().upper().startswith('SELECT'): + statements.append(statement) + + async def session_dependency() -> AsyncIterator[AsyncSession]: + async with sessions() as db: + yield db + + app = FastAPI() + app.state.redis = redis + app.dependency_overrides[get_db_session_provider(None)] = session_dependency + handle_exception(app) + router = APIRouter(dependencies=[PreAuthDependency()]) + + @router.get('/profile') + @router.get( + '/permitted', + dependencies=[UserInterfaceAuthDependency('demo:read')], + ) + async def profile(current_user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> CurrentUserModel: + return current_user + + app.include_router(router) + headers = {} + for user_id in (ADMIN_ID, USER_ID): + token = JwtUtil.encode( + { + 'user_id': str(user_id), + 'session_id': f'session-{user_id}', + 'exp': int(TimezoneUtil.utc_now().timestamp()) + 3600, + } + ) + await redis.set(f'access_token:session-{user_id}', token, ex=3600) + headers[user_id] = {'Authorization': f'Bearer {token}'} + + RequestContext.clear_all() + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as client: + yield SimpleNamespace( + app=app, client=client, redis=redis, sessions=sessions, headers=headers, statements=statements + ) + finally: + RequestContext.clear_all() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_double_auth_declaration_queries_and_renews_once_per_request(resolution_env: SimpleNamespace) -> None: + first = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert first.status_code == HttpStatusConstant.SUCCESS + assert len(resolution_env.statements) == REGULAR_QUERY_COUNT + assert len(resolution_env.redis.renew_calls) == 1 + payload = first.json() + assert payload['permissions'] == ['demo:read'] + assert payload['roles'] == ['reader'] + assert payload['user']['postIds'] == str(USER_ID) + assert payload['user']['roleIds'] == str(USER_ID) + assert payload['user']['dept']['deptName'] == 'Engineering' + assert payload['user']['dept']['ancestors'] == '0,1' + assert payload['user']['role'][0]['dataScope'] == '4' + assert payload['user']['password'] is None + assert 'private-password-hash' not in first.text + + second = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert second.json() == payload + assert len(resolution_env.statements) == REGULAR_QUERY_COUNT * TWO_REQUESTS + assert len(resolution_env.redis.renew_calls) == TWO_REQUESTS + + +@pytest.mark.asyncio +async def test_admin_does_not_load_menus_for_authentication(resolution_env: SimpleNamespace) -> None: + response = await resolution_env.client.get('/permitted', headers=resolution_env.headers[ADMIN_ID]) + assert response.json()['permissions'] == ['*:*:*'] + assert response.json()['roles'] == ['admin'] + assert response.json()['user']['admin'] is True + assert len(resolution_env.statements) == ADMIN_QUERY_COUNT + assert not any('sys_menu' in statement for statement in resolution_env.statements) + assert len(resolution_env.redis.renew_calls) == 1 + + +@pytest.mark.asyncio +async def test_use_cache_false_queries_and_renews_again_in_the_same_request(resolution_env: SimpleNamespace) -> None: + @resolution_env.app.get('/revalidate', dependencies=[PreAuthDependency()]) + async def revalidate( + first: Annotated[CurrentUserModel, CurrentUserDependency()], + second: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + ) -> dict: + return { + 'sameUser': first is second, + 'permissions': second.permissions, + 'contextIsFresh': RequestContext.get_current_user() is second, + } + + response = await resolution_env.client.get('/revalidate', headers=resolution_env.headers[USER_ID]) + + assert response.status_code == HttpStatusConstant.SUCCESS + assert response.json() == {'sameUser': False, 'permissions': ['demo:read'], 'contextIsFresh': True} + assert len(resolution_env.statements) == REGULAR_QUERY_COUNT * TWO_AUTHENTICATIONS + assert len(resolution_env.redis.renew_calls) == TWO_AUTHENTICATIONS + + +@pytest.mark.asyncio +async def test_use_cache_false_rejects_a_session_revoked_in_the_same_request(resolution_env: SimpleNamespace) -> None: + async def revoke_session(current_user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> None: + assert current_user.user.user_id == USER_ID + await resolution_env.redis.delete(f'access_token:session-{USER_ID}') + + @resolution_env.app.get('/revoke-session', dependencies=[PreAuthDependency(), Depends(revoke_session)]) + async def revalidate( + current_user: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + ) -> CurrentUserModel: + pytest.fail('已撤销的会话不应进入接口') + + response = await resolution_env.client.get('/revoke-session', headers=resolution_env.headers[USER_ID]) + + assert response.json()['code'] == HttpStatusConstant.UNAUTHORIZED + assert len(resolution_env.statements) == REGULAR_QUERY_COUNT + assert len(resolution_env.redis.renew_calls) == 1 + assert await resolution_env.redis.get(f'access_token:session-{USER_ID}') is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', ['remove_menu', 'disable_role']) +async def test_use_cache_false_applies_permission_changes_in_the_same_request( + resolution_env: SimpleNamespace, change: str +) -> None: + async def revoke_permission( + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + ) -> None: + assert current_user.permissions == ['demo:read'] + if change == 'remove_menu': + await query_db.execute(delete(SysRoleMenu).where(SysRoleMenu.role_id == USER_ID)) + else: + await query_db.execute(update(SysRole).where(SysRole.role_id == USER_ID).values(status='1')) + await query_db.commit() + + @resolution_env.app.get( + '/revoke-permission', + dependencies=[ + PreAuthDependency(), + Depends(revoke_permission), + ], + ) + async def revalidate( + current_user: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + permitted: Annotated[bool, UserInterfaceAuthDependency('demo:read')], + ) -> None: + pytest.fail('权限撤销后不应进入接口') + + response = await resolution_env.client.get('/revoke-permission', headers=resolution_env.headers[USER_ID]) + + assert response.json()['code'] == HttpStatusConstant.FORBIDDEN + assert len(resolution_env.statements) == REGULAR_QUERY_COUNT * TWO_AUTHENTICATIONS + assert len(resolution_env.redis.renew_calls) == TWO_AUTHENTICATIONS + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', [{'status': '1'}, {'del_flag': '2'}]) +async def test_disabled_or_deleted_user_is_rejected_on_next_request( + resolution_env: SimpleNamespace, change: dict[str, str] +) -> None: + first = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert first.json()['user']['userId'] == USER_ID + async with resolution_env.sessions.begin() as db: + await db.execute(update(SysUser).where(SysUser.user_id == USER_ID).values(**change)) + + second = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert second.json()['code'] == HttpStatusConstant.UNAUTHORIZED + assert len(resolution_env.statements) == REGULAR_QUERY_COUNT + 1 + assert len(resolution_env.redis.renew_calls) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', ['remove', 'disable', 'delete']) +async def test_role_revocation_applies_to_next_request(resolution_env: SimpleNamespace, change: str) -> None: + first = await resolution_env.client.get('/permitted', headers=resolution_env.headers[USER_ID]) + assert first.json()['permissions'] == ['demo:read'] + async with resolution_env.sessions.begin() as db: + if change == 'remove': + await db.execute(delete(SysUserRole).where(SysUserRole.user_id == USER_ID)) + else: + values = {'status': '1'} if change == 'disable' else {'del_flag': '2'} + await db.execute(update(SysRole).where(SysRole.role_id == USER_ID).values(**values)) + + second = await resolution_env.client.get('/permitted', headers=resolution_env.headers[USER_ID]) + assert second.json()['code'] == HttpStatusConstant.FORBIDDEN + assert len(resolution_env.statements) == REGULAR_QUERY_COUNT * TWO_REQUESTS + assert len(resolution_env.redis.renew_calls) == TWO_REQUESTS + profile = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert profile.json()['roles'] == [] + assert profile.json()['permissions'] == [] + + +@pytest.mark.asyncio +async def test_password_policy_changes_are_recomputed_on_next_request( + resolution_env: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + first = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert first.json()['pwdChrtype'] == '0' + assert not first.json()['isDefaultModifyPwd'] + assert not first.json()['isPasswordExpired'] + resolution_env.redis.values.update( + { + 'sys_config:sys.account.chrtype': '4', + 'sys_config:sys.account.initPasswordModify': '1', + 'sys_config:sys.account.passwordValidateDays': '1', + } + ) + monkeypatch.setattr(AppConfig, 'app_timezone', 'UTC') + + second = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert second.json()['pwdChrtype'] == '4' + assert second.json()['isDefaultModifyPwd'] + assert second.json()['isPasswordExpired'] + assert second.json()['appTimezone'] == 'UTC' + assert len(resolution_env.statements) == REGULAR_QUERY_COUNT * TWO_REQUESTS + assert len(resolution_env.redis.renew_calls) == TWO_REQUESTS + + +@pytest.mark.asyncio +async def test_password_expiry_advances_between_requests( + resolution_env: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + now = TimezoneUtil.utc_now() + async with resolution_env.sessions.begin() as db: + await db.execute(update(SysUser).where(SysUser.user_id == USER_ID).values(pwd_update_date=now)) + resolution_env.redis.values['sys_config:sys.account.passwordValidateDays'] = '1' + first = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert not first.json()['isPasswordExpired'] + monkeypatch.setattr(TimezoneUtil, 'utc_now', staticmethod(lambda: now + timedelta(days=2))) + + second = await resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]) + assert second.json()['isPasswordExpired'] + + +@pytest.mark.asyncio +async def test_concurrent_requests_keep_user_identity_and_permissions_separate(resolution_env: SimpleNamespace) -> None: + admin, reader = await asyncio.gather( + resolution_env.client.get('/profile', headers=resolution_env.headers[ADMIN_ID]), + resolution_env.client.get('/profile', headers=resolution_env.headers[USER_ID]), + ) + assert admin.json()['user']['userId'] == ADMIN_ID + assert admin.json()['permissions'] == ['*:*:*'] + assert reader.json()['user']['userId'] == USER_ID + assert reader.json()['permissions'] == ['demo:read'] + assert len(resolution_env.statements) == ADMIN_QUERY_COUNT + REGULAR_QUERY_COUNT + assert len(resolution_env.redis.renew_calls) == TWO_REQUESTS + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['get', 'eval']) +@pytest.mark.parametrize('failure', [RedisConnectionError, RedisTimeoutError, NoPermissionError]) +async def test_redis_failure_rejects_protected_requests_and_recovers_without_relogin( + resolution_env: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + operation: str, + failure: type[RedisError], +) -> None: + resolution_env.app.add_middleware(ContextCleanupMiddleware) + + @resolution_env.app.get('/public-health', dependencies=[PreAuthDependency([{'path': '/public-health'}])]) + async def public_health() -> dict: + return {'status': 'ok'} + + original = getattr(resolution_env.redis, operation) + unavailable = AsyncMock(side_effect=failure('cache unavailable')) + monkeypatch.setattr(resolution_env.redis, operation, unavailable) + key = f'access_token:session-{USER_ID}' + token = resolution_env.redis.values[key] + deadline = resolution_env.redis.deadlines[key] + transport = ASGITransport(app=resolution_env.app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url='http://test') as client: + public = await client.get('/public-health') + assert public.json() == {'status': 'ok'} + unavailable.assert_not_awaited() + + failed = await client.get('/permitted', headers=resolution_env.headers[USER_ID]) + # 基础设施故障保留服务错误语义,不能误报令牌失效或放行受保护接口。 + assert failed.json()['code'] == HttpStatusConstant.ERROR + assert 'permissions' not in failed.json() + assert resolution_env.redis.values[key] == token + assert resolution_env.redis.deadlines[key] == deadline + assert resolution_env.redis.renew_calls == [] + + monkeypatch.setattr(resolution_env.redis, operation, original) + recovered = await client.get('/permitted', headers=resolution_env.headers[USER_ID]) + + assert recovered.json()['user']['userId'] == USER_ID + assert recovered.json()['permissions'] == ['demo:read'] + assert len(resolution_env.redis.renew_calls) == 1 + expected_queries = REGULAR_QUERY_COUNT * (TWO_REQUESTS if operation == 'eval' else 1) + assert len(resolution_env.statements) == expected_queries diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py index cd3cb63b9..c19cc5775 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py @@ -14,6 +14,8 @@ from starlette.types import Receive, Scope, Send from common.aspect.db_session import get_db_session_provider +from common.context import RequestContext +from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel from module_admin.service.login_service import LoginService from plugins.core.discovery.registry import PluginRegistry, RegisteredPlugin from plugins.core.discovery.scanner import PluginScanner @@ -167,6 +169,72 @@ async def denied(): await runtime.shutdown() +@pytest.mark.asyncio +@pytest.mark.parametrize('override_dependency', [False, True]) +async def test_router_and_endpoint_share_current_user_dependency( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, override_dependency: bool +) -> None: + source = """ +from fastapi import APIRouter, Depends, Request +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from module_admin.service.login_service import LoginService +from plugins.core.sdk import PluginDefinition +def create_plugin(host): + router = APIRouter( + prefix='/runtime_test', + dependencies=[PreAuthDependency([{'path': '/runtime_test/info'}])], + ) + @router.get('/info') + async def info( + request: Request, + current_user=CurrentUserDependency(), + direct_user=Depends(LoginService.get_current_user), + ): + return { + 'sameUser': current_user is direct_user is request.state.plugin_context.user, + 'permissions': current_user.permissions, + } + return PluginDefinition(routers=(router,)) +""" + plugin = write_plugin(tmp_path, source=source, integration='router') + app = FastAPI() + app.dependency_overrides[get_db_session_provider(None)] = object + user = CurrentUserModel( + permissions=['runtime_test:view'], roles=['reader'], user=UserInfoModel(userId=7, userName='tester') + ) + resolver = AsyncMock(return_value=user) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + override_calls = [] + + async def current_user() -> CurrentUserModel: + override_calls.append(user) + return user + + if override_dependency: + app.dependency_overrides[LoginService.get_current_user] = current_user + runtime = ExplicitPluginRuntime(SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True))) + runtime.prepare(plugin, app, startup_write_enabled=False) + try: + await runtime.activate('runtime_test', app) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + if not override_dependency: + missing = await client.get('/runtime_test/info') + assert missing.status_code == status.HTTP_401_UNAUTHORIZED + resolver.assert_not_awaited() + headers = {} if override_dependency else {'Authorization': 'Bearer test'} + response = await client.get('/runtime_test/info', headers=headers) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {'sameUser': True, 'permissions': ['runtime_test:view']} + if override_dependency: + assert override_calls == [user] + resolver.assert_not_awaited() + else: + resolver.assert_awaited_once() + finally: + await runtime.shutdown() + RequestContext.clear_all() + + def test_dependency_cycle_and_missing_dependency_do_not_block_independent_plugin(tmp_path: Path) -> None: first = write_plugin(tmp_path, plugin_id='runtime_test_a', dependencies=['runtime_test_b']) second = write_plugin(tmp_path, plugin_id='runtime_test_b', dependencies=['runtime_test_a']) diff --git a/ruoyi-fastapi-backend/utils/dependency_util.py b/ruoyi-fastapi-backend/utils/dependency_util.py index 23ace9d27..7a719660c 100644 --- a/ruoyi-fastapi-backend/utils/dependency_util.py +++ b/ruoyi-fastapi-backend/utils/dependency_util.py @@ -10,6 +10,27 @@ class DependencyUtil: 依赖项工具类 """ + @classmethod + def get_request_path(cls, request: Request) -> str: + """ + 获取不含部署前缀的应用内请求路径 + + :param request: 当前请求对象 + :return: 应用内请求路径 + """ + scope = getattr(request, 'scope', {}) + # 使用路由匹配的ASGI路径,避免URL解析截断或丢弃路径字符。 + path = scope.get('path') + if path is None: + path = request.url.path + root_path = (scope.get('root_path') or '').rstrip('/') + if not root_path and scope.get('route') is None: + # 直接调用依赖时可能没有ASGI路由上下文,保留配置前缀的处理。 + root_path = (AppConfig.app_root_path or '').rstrip('/') + if root_path and (path == root_path or path.startswith(f'{root_path}/')): + return path[len(root_path) :] + return path + @classmethod def check_exclude_routes(cls, request: Request, err_msg: str = '当前路由不在认证规则内,不可使用该依赖项') -> None: """ @@ -20,16 +41,9 @@ def check_exclude_routes(cls, request: Request, err_msg: str = '当前路由不 :return: None """ # 获取当前请求路径和方法 - path = request.url.path + path = cls.get_request_path(request) method = request.method.upper() - # 从配置中获取APP_ROOT_PATH - app_root_path = AppConfig.app_root_path - - # 去掉APP_ROOT_PATH前缀 - if app_root_path and path.startswith(app_root_path): - path = path[len(app_root_path) :] - # 获取编译后的排除路由模式列表 exclude_patterns = RequestContext.get_current_exclude_patterns() @@ -45,5 +59,5 @@ def check_exclude_routes(cls, request: Request, err_msg: str = '当前路由不 continue # 检查路径是否匹配,并且methods为空列表(匹配所有方法)或者当前方法在允许列表中 - if pattern.match(path) and (not exclude_methods or method in exclude_methods): + if pattern.fullmatch(path) and (not exclude_methods or method in exclude_methods): raise PermissionException(data='', message=err_msg) From 76ec174798c44a3aa06a0c7974d4431a3e7bed9e Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 28 Oct 2026 17:20:44 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=E5=90=8C=E6=AD=A5=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E7=9A=84=20OpenAPI=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_api_docs_auth.py | 829 ++++++++++++++++++ ruoyi-fastapi-backend/utils/openapi_util.py | 391 +++++++++ ruoyi-fastapi-backend/utils/server_util.py | 32 +- 3 files changed, 1235 insertions(+), 17 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/test_api_docs_auth.py create mode 100644 ruoyi-fastapi-backend/utils/openapi_util.py diff --git a/ruoyi-fastapi-backend/tests/test_api_docs_auth.py b/ruoyi-fastapi-backend/tests/test_api_docs_auth.py new file mode 100644 index 000000000..7f806f3ea --- /dev/null +++ b/ruoyi-fastapi-backend/tests/test_api_docs_auth.py @@ -0,0 +1,829 @@ +import json +from collections.abc import AsyncIterator, Iterator +from copy import deepcopy +from types import SimpleNamespace +from typing import Annotated +from unittest.mock import AsyncMock +from uuid import UUID + +import httpx +import pytest +from fastapi import APIRouter, Depends, FastAPI, Request, status +from fastapi.security import APIKeyHeader, OAuth2PasswordBearer + +from common.aspect.db_session import get_db_session_provider +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.context import RequestContext +from config.env import AppConfig +from exceptions.exception import AuthException +from exceptions.handle import handle_exception +from middlewares.context_middleware import add_context_cleanup_middleware +from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel +from module_admin.service.login_service import LoginService, oauth2_scheme +from utils.server_util import APIDocsUtil + +LOGIN_SECURITY = {'OAuth2PasswordBearer': []} +AUTHORIZATION = {'Authorization': 'Bearer valid'} + + +@pytest.fixture +def docs_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[SimpleNamespace]: + monkeypatch.setattr(AppConfig, 'app_disable_swagger', False) + monkeypatch.setattr(AppConfig, 'app_disable_redoc', False) + monkeypatch.setattr(AppConfig, 'app_root_path', '') + app = FastAPI( + openapi_url=APIDocsUtil.proxy_openapi_url(), + servers=[{'url': 'https://api.example.test'}], + ) + APIDocsUtil.custom_api_docs_router(app) + handle_exception(app) + add_context_cleanup_middleware(app) + + async def database() -> AsyncIterator[object]: + yield object() + + async def authenticate(request: Request, token: str, db: object, *, refresh_session: bool) -> CurrentUserModel: + assert refresh_session + if token != 'valid': + raise AuthException(message='用户token已失效,请重新登录') + current_user = CurrentUserModel( + permissions=['demo:read'], + roles=['reader'], + user=UserInfoModel(userId=2, userName='reader'), + ) + RequestContext.set_current_user(current_user) + return current_user + + resolver = AsyncMock(side_effect=authenticate) + monkeypatch.setattr(LoginService, '_resolve_current_user', resolver) + app.dependency_overrides[get_db_session_provider(None)] = database + + @app.get('/public', dependencies=[PreAuthDependency([{'path': '/public'}])]) + async def public() -> dict: + return {'public': True} + + @app.get('/protected', dependencies=[PreAuthDependency()]) + async def protected() -> dict: + return {'userId': RequestContext.get_current_user().user.user_id} + + @app.api_route( + '/method', + methods=['GET', 'POST'], + dependencies=[PreAuthDependency([{'path': '/method', 'methods': ['GET']}])], + ) + async def method() -> dict: + return {'ok': True} + + @app.get('/items/{name}', dependencies=[PreAuthDependency([{'path': '/items/{name}'}])]) + async def items(name: str) -> dict: + return {'name': name} + + @app.get( + '/mixed/{name}', + dependencies=[PreAuthDependency([{'path': '/mixed/{name}', 'ignore_paths': ['/mixed/private']}])], + ) + async def mixed(name: str) -> dict: + return {'name': name} + + @app.get('/partial/{name}', dependencies=[PreAuthDependency([{'path': '/partial/free'}])]) + async def partial(name: str) -> dict: + return {'name': name} + + try: + yield SimpleNamespace(app=app, resolver=resolver) + finally: + RequestContext.clear_all() + + +def _security(app: FastAPI, path: str, method: str = 'get') -> list[dict]: + return app.openapi()['paths'][path][method].get('security', []) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('server_urls', [[], ['https://api.example.test'], ['/gateway']]) +async def test_proxy_schema_does_not_add_deployment_prefix_to_direct_schema( + docs_app: SimpleNamespace, server_urls: list[str] +) -> None: + servers = [{'url': url} for url in server_urls] + docs_app.app.servers = deepcopy(servers) + transport = httpx.ASGITransport(app=docs_app.app, root_path='/gateway') + async with httpx.AsyncClient(transport=transport, base_url='http://test') as client: + proxy = await client.get('/gateway/proxy-openapi.json') + direct = await client.get('/gateway/openapi.json') + assert proxy.status_code == direct.status_code == status.HTTP_200_OK + expected_proxy_servers = servers if '/gateway' in server_urls else [{'url': '/gateway'}, *servers] + assert proxy.json().get('servers', []) == expected_proxy_servers + assert direct.json().get('servers', []) == servers + assert proxy.json()['paths'] == direct.json()['paths'] + assert docs_app.app.servers == servers + assert docs_app.app.openapi_schema.get('servers', []) == servers + + +@pytest.mark.asyncio +@pytest.mark.parametrize('path', ['/public', '/items/{name}']) +async def test_fully_excluded_route_has_no_login_security(docs_app: SimpleNamespace, path: str) -> None: + assert _security(docs_app.app, path) == [] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + response = await client.get(path.replace('{name}', 'free'), headers={'Authorization': 'Bearer invalid'}) + assert response.status_code == status.HTTP_200_OK + assert response.json().get('code') != status.HTTP_401_UNAUTHORIZED + docs_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_protected_route_keeps_login_security(docs_app: SimpleNamespace) -> None: + assert _security(docs_app.app, '/protected') == [LOGIN_SECURITY] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + denied = await client.get('/protected') + allowed = await client.get('/protected', headers=AUTHORIZATION) + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert allowed.json() == {'userId': 2} + docs_app.resolver.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_security_is_classified_per_http_method(docs_app: SimpleNamespace) -> None: + assert _security(docs_app.app, '/method', 'get') == [] + assert _security(docs_app.app, '/method', 'post') == [LOGIN_SECURITY] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + public = await client.get('/method') + protected = await client.post('/method') + assert public.json() == {'ok': True} + assert protected.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('path', 'public_path', 'protected_path', 'documented_rule'), + [ + ('/mixed/{name}', '/mixed/free', '/mixed/private', '/mixed/private'), + ('/partial/{name}', '/partial/free', '/partial/private', '/partial/free'), + ], +) +async def test_parameter_dependent_authentication_is_documented_as_conditional( + docs_app: SimpleNamespace, path: str, public_path: str, protected_path: str, documented_rule: str +) -> None: + operation = docs_app.app.openapi()['paths'][path]['get'] + assert LOGIN_SECURITY in operation['security'] + assert {} in operation['security'] + assert operation['x-login-auth']['mode'] == 'conditional' + assert documented_rule in json.dumps(operation['x-login-auth']) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + public = await client.get(public_path) + protected = await client.get(protected_path) + assert public.status_code == status.HTTP_200_OK + assert public.json().get('code') != status.HTTP_401_UNAUTHORIZED + assert protected.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +@pytest.mark.parametrize('declaration', ['current', 'nested', 'token', 'plugin', 'included']) +async def test_explicit_authentication_cannot_be_removed_from_public_route_schema( + docs_app: SimpleNamespace, declaration: str +) -> None: + path = f'/explicit-{declaration}' + dependencies = [PreAuthDependency([{'path': path}])] + + async def identity(user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> int: + return user.user.user_id + + if declaration == 'current': + + @docs_app.app.get(path, dependencies=dependencies) + async def current(user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> dict: + return {'userId': user.user.user_id} + + elif declaration == 'nested': + + @docs_app.app.get(path, dependencies=dependencies) + async def nested(user_id: Annotated[int, Depends(identity)]) -> dict: + return {'userId': user_id} + + elif declaration == 'token': + + @docs_app.app.get(path, dependencies=dependencies) + async def token(value: Annotated[str, Depends(oauth2_scheme)]) -> dict: + return {'token': value} + + else: + router = APIRouter(dependencies=dependencies) + + @router.get(path) + async def plugin() -> dict: + return {'ok': True} + + dependency = identity if declaration == 'plugin' else LoginService.get_current_user + docs_app.app.include_router(router, dependencies=[Depends(dependency)]) + + assert _security(docs_app.app, path) == [LOGIN_SECURITY] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + denied = await client.get(path) + allowed = await client.get(path, headers=AUTHORIZATION) + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert allowed.status_code == status.HTTP_200_OK + assert allowed.json().get('code') != status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +@pytest.mark.parametrize('required', [False, True]) +async def test_all_pre_auth_dependencies_must_exclude_the_route(docs_app: SimpleNamespace, required: bool) -> None: + path = '/multiple' + dependencies = [ + PreAuthDependency([{'path': path}]), + PreAuthDependency([] if required else [{'path': path}]), + ] + + @docs_app.app.get(path, dependencies=dependencies) + async def multiple() -> dict: + return {'ok': True} + + assert _security(docs_app.app, path) == ([LOGIN_SECURITY] if required else []) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + response = await client.get(path) + if required: + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + else: + assert response.json() == {'ok': True} + + +@pytest.mark.asyncio +async def test_disjoint_exclusions_do_not_make_a_parameterized_route_public(docs_app: SimpleNamespace) -> None: + @docs_app.app.get( + '/disjoint/{name}', + dependencies=[ + PreAuthDependency([{'path': '/disjoint/first'}]), + PreAuthDependency([{'path': '/disjoint/second'}]), + ], + ) + async def disjoint(name: str) -> dict: + return {'name': name} + + assert _security(docs_app.app, '/disjoint/{name}') == [LOGIN_SECURITY] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + for name in ['first', 'second', 'other']: + response = await client.get(f'/disjoint/{name}') + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.parametrize('name', ['public', 'private']) +def test_fixed_route_honors_wildcard_exclusion_and_ignore_paths(docs_app: SimpleNamespace, name: str) -> None: + path = f'/fixed/{name}' + + @docs_app.app.get( + path, + dependencies=[PreAuthDependency([{'path': '/fixed/{name}', 'ignore_paths': ['/fixed/private']}])], + ) + async def fixed() -> dict: + return {'ok': True} + + assert _security(docs_app.app, path) == ([LOGIN_SECURITY] if name == 'private' else []) + + +@pytest.mark.parametrize('ignore_path', ['/elsewhere/private', '/reallowed/private']) +def test_irrelevant_or_reallowed_exceptions_do_not_make_fully_public_routes_conditional( + docs_app: SimpleNamespace, ignore_path: str +) -> None: + @docs_app.app.get( + '/reallowed/{name}', + dependencies=[ + PreAuthDependency( + [ + {'path': '/reallowed/{name}', 'ignore_paths': [ignore_path]}, + {'path': '/reallowed/private'}, + ] + ) + ], + ) + async def reallowed(name: str) -> dict: + return {'name': name} + + assert _security(docs_app.app, '/reallowed/{name}') == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('conditional', [False, True]) +async def test_public_login_exclusion_preserves_other_required_security( + docs_app: SimpleNamespace, conditional: bool +) -> None: + path = '/key/{name}' if conditional else '/key' + rule = {'path': '/key/free' if conditional else path} + api_key = APIKeyHeader(name='X-API-Key', scheme_name='ServiceKey') + + @docs_app.app.get(path, dependencies=[PreAuthDependency([rule])]) + async def key(value: Annotated[str, Depends(api_key)]) -> dict: + return {'key': value} + + security = _security(docs_app.app, path) + assert security + assert all('ServiceKey' in requirement for requirement in security) + if not conditional: + assert security == [{'ServiceKey': []}] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + public_path = '/key/free' if conditional else path + denied = await client.get(public_path) + allowed = await client.get(public_path, headers={'X-API-Key': 'service-secret'}) + assert denied.status_code == status.HTTP_401_UNAUTHORIZED + assert allowed.json() == {'key': 'service-secret'} + docs_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_independent_oauth_scheme_with_the_same_name_remains_required(docs_app: SimpleNamespace) -> None: + independent = OAuth2PasswordBearer(tokenUrl='independent-login', scheme_name='OAuth2PasswordBearer') + + @docs_app.app.get('/independent', dependencies=[PreAuthDependency([{'path': '/independent'}])]) + async def independent_token(token: Annotated[str, Depends(independent)]) -> dict: + return {'token': token} + + assert _security(docs_app.app, '/independent') == [LOGIN_SECURITY] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + denied = await client.get('/independent') + allowed = await client.get('/independent', headers=AUTHORIZATION) + assert denied.status_code == status.HTTP_401_UNAUTHORIZED + assert allowed.json() == {'token': 'valid'} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('root_path', ['/gateway', '/api']) +async def test_nested_router_prefix_and_root_path_match_runtime_rules( + docs_app: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, root_path: str +) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', root_path) + docs_app.app.root_path = root_path + child = APIRouter(prefix='/child', dependencies=[PreAuthDependency([{'path': '/api/child/public'}])]) + + @child.get('/public') + async def public() -> dict: + return {'ok': True} + + parent = APIRouter(prefix='/api') + parent.include_router(child) + docs_app.app.include_router(parent) + assert _security(docs_app.app, '/api/child/public') == [] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + response = await client.get(f'{root_path}/api/child/public') + assert response.json() == {'ok': True} + docs_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_schema_urls_and_direct_schema_share_security_and_servers(docs_app: SimpleNamespace) -> None: + direct = docs_app.app.openapi() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + custom = await client.get('/openapi.json') + proxy = await client.get('/proxy-openapi.json') + assert custom.status_code == proxy.status_code == status.HTTP_200_OK + assert custom.json() == proxy.json() == direct + assert direct['servers'] == [{'url': 'https://api.example.test'}] + assert direct['paths']['/public']['get'].get('security', []) == [] + assert direct['paths']['/protected']['get']['security'] == [LOGIN_SECURITY] + docs_app.resolver.assert_not_awaited() + + +def test_schema_adjustment_is_idempotent_and_tracks_late_router_registration(docs_app: SimpleNamespace) -> None: + initial = deepcopy(docs_app.app.openapi()) + assert docs_app.app.openapi() == initial + router = APIRouter(prefix='/late', dependencies=[PreAuthDependency([{'path': '/late/public'}])]) + + @router.get('/public') + async def public() -> dict: + return {'ok': True} + + @router.get('/protected') + async def protected() -> dict: + return {'ok': True} + + docs_app.app.include_router(router) + updated = deepcopy(docs_app.app.openapi()) + assert '/late/public' not in initial['paths'] + assert updated['paths']['/late/public']['get'].get('security', []) == [] + assert updated['paths']['/late/protected']['get']['security'] == [LOGIN_SECURITY] + assert docs_app.app.openapi() == updated + docs_app.app.openapi_schema = None + assert docs_app.app.openapi() == updated + docs_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('public_first', [False, True]) +@pytest.mark.parametrize('include_first', [False, True]) +async def test_duplicate_route_preserves_native_security( + docs_app: SimpleNamespace, public_first: bool, include_first: bool +) -> None: + path = '/duplicate' + + @docs_app.app.get( + path, + dependencies=[PreAuthDependency([{'path': path}] if public_first else [])], + include_in_schema=include_first, + ) + async def first() -> dict: + return {'route': 'first'} + + @docs_app.app.get(path, dependencies=[PreAuthDependency([] if public_first else [{'path': path}])]) + async def second() -> dict: + return {'route': 'second'} + + operation = docs_app.app.openapi()['paths'][path]['get'] + assert operation == docs_app.app.openapi_schema['paths'][path]['get'] + assert operation['security'] == [LOGIN_SECURITY] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + anonymous = await client.get(path) + authenticated = await client.get(path, headers=AUTHORIZATION) + if public_first: + assert anonymous.json() == {'route': 'first'} + else: + assert anonymous.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert authenticated.json() == {'route': 'first'} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('public_first', [False, True]) +@pytest.mark.parametrize('include_first', [False, True]) +async def test_duplicate_path_with_different_convertors_preserves_native_security( + docs_app: SimpleNamespace, public_first: bool, include_first: bool +) -> None: + rule = {'path': '/duplicate-convertor/{value}'} + + @docs_app.app.get( + '/duplicate-convertor/{value:int}', + dependencies=[PreAuthDependency([rule] if public_first else [])], + include_in_schema=include_first, + ) + async def integer(value: int) -> dict: + return {'route': 'integer', 'value': value} + + @docs_app.app.get('/duplicate-convertor/{value}', dependencies=[PreAuthDependency([] if public_first else [rule])]) + async def string(value: str) -> dict: + return {'route': 'string', 'value': value} + + path = '/duplicate-convertor/{value}' + operation = docs_app.app.openapi()['paths'][path]['get'] + # 相同OpenAPI路径可对应不同的实际匹配范围,不能将其中一条的排除规则用于整条声明。 + assert operation['security'] == docs_app.app.openapi_schema['paths'][path]['get']['security'] == [LOGIN_SECURITY] + assert 'x-login-auth' not in operation + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + numeric = await client.get('/duplicate-convertor/1') + text = await client.get('/duplicate-convertor/free') + public, protected = (numeric, text) if public_first else (text, numeric) + assert public.json() == ({'route': 'integer', 'value': 1} if public_first else {'route': 'string', 'value': 'free'}) + assert protected.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('first_auth', 'second_auth'), + [ + ('protected', 'plain'), + ('plain', 'protected'), + ('public', 'plain'), + ('plain', 'public'), + ('public', 'key'), + ('key', 'public'), + ('protected', 'key'), + ('key', 'protected'), + ('public_key', 'protected'), + ('protected', 'public_key'), + ], +) +async def test_duplicate_route_with_other_authentication_preserves_native_security( + docs_app: SimpleNamespace, first_auth: str, second_auth: str +) -> None: + path = '/duplicate-security' + api_key = APIKeyHeader(name='X-API-Key', scheme_name='DuplicateKey') + dependencies = { + 'protected': [PreAuthDependency()], + 'public': [PreAuthDependency([{'path': path}])], + 'plain': [], + 'key': [Depends(api_key)], + 'public_key': [PreAuthDependency([{'path': path}]), Depends(api_key)], + } + + @docs_app.app.get(path, dependencies=dependencies[first_auth]) + async def first() -> dict: + return {'route': 'first'} + + @docs_app.app.get(path, dependencies=dependencies[second_auth]) + async def second() -> dict: + return {'route': 'second'} + + operation = docs_app.app.openapi()['paths'][path]['get'] + assert operation == docs_app.app.openapi_schema['paths'][path]['get'] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + anonymous = await client.get(path) + authenticated = await client.get(path, headers={**AUTHORIZATION, 'X-API-Key': 'service-secret'}) + if first_auth == 'protected': + assert anonymous.json()['code'] == status.HTTP_401_UNAUTHORIZED + elif first_auth in ('key', 'public_key'): + assert anonymous.status_code == status.HTTP_401_UNAUTHORIZED + else: + assert anonymous.json() == {'route': 'first'} + assert authenticated.json() == {'route': 'first'} + + +def test_unrelated_schema_details_and_security_schemes_are_preserved(docs_app: SimpleNamespace) -> None: + api_key = APIKeyHeader(name='X-External-Key', scheme_name='ExternalKey') + + @docs_app.app.get( + '/external', + summary='外部认证接口', + description='保留已有接口描述。', + tags=['外部接口'], + openapi_extra={'x-external': {'enabled': True}}, + ) + async def external(key: Annotated[str, Depends(api_key)]) -> dict: + return {'key': key} + + @docs_app.app.get('/plain-public') + async def plain_public() -> dict: + return {'ok': True} + + schema = docs_app.app.openapi() + operation = schema['paths']['/external']['get'] + assert operation['security'] == [{'ExternalKey': []}] + assert operation['summary'] == '外部认证接口' + assert operation['description'] == '保留已有接口描述。' + assert operation['tags'] == ['外部接口'] + assert operation['x-external'] == {'enabled': True} + assert 'x-login-auth' not in operation + assert schema['paths']['/plain-public']['get'].get('security', []) == [] + assert schema['components']['securitySchemes']['ExternalKey']['name'] == 'X-External-Key' + assert schema['components']['securitySchemes']['OAuth2PasswordBearer']['flows']['password']['tokenUrl'] == 'login' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('converter', 'first_value', 'second_value', 'excluded_value'), + [ + ('int', '1', '2', '1'), + ('int', '1', '2', 'free'), + ( + 'uuid', + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000001', + ), + ( + 'uuid', + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + 'free', + ), + ], +) +async def test_parameter_convertors_exclude_impossible_public_values( + docs_app: SimpleNamespace, converter: str, first_value: str, second_value: str, excluded_value: str +) -> None: + @docs_app.app.get( + f'/converted/{{value:{converter}}}', + dependencies=[PreAuthDependency([{'path': f'/converted/{excluded_value}'}])], + ) + async def converted(value: int | UUID) -> dict: + return {'value': str(value)} + + security = _security(docs_app.app, '/converted/{value}') + has_public_value = first_value == excluded_value + assert LOGIN_SECURITY in security + assert ({} in security) is has_public_value + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + first = await client.get(f'/converted/{first_value}') + second = await client.get(f'/converted/{second_value}') + if has_public_value: + assert first.json() == {'value': first_value} + else: + assert first.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert second.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +@pytest.mark.parametrize('reallow', [False, True]) +async def test_overlapping_public_values_respect_each_pre_auth_exception( + docs_app: SimpleNamespace, reallow: bool +) -> None: + rules = [{'path': '/overlap/{name}', 'ignore_paths': ['/overlap/free']}] + if reallow: + rules.append({'path': '/overlap/free'}) + + @docs_app.app.get( + '/overlap/{name}', + dependencies=[PreAuthDependency(rules), PreAuthDependency([{'path': '/overlap/free'}])], + ) + async def overlap(name: str) -> dict: + return {'name': name} + + security = _security(docs_app.app, '/overlap/{name}') + assert LOGIN_SECURITY in security + assert ({} in security) is reallow + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + first = await client.get('/overlap/free') + second = await client.get('/overlap/private') + if reallow: + assert first.json() == {'name': 'free'} + else: + assert first.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert second.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +def test_adjusted_schema_never_mutates_the_native_cache(docs_app: SimpleNamespace) -> None: + adjusted = docs_app.app.openapi() + native = docs_app.app.openapi_schema + assert native is not adjusted + assert native['paths']['/public']['get']['security'] == [LOGIN_SECURITY] + assert native['paths']['/mixed/{name}']['get']['security'] == [LOGIN_SECURITY] + assert 'x-login-auth' not in native['paths']['/mixed/{name}']['get'] + snapshot = deepcopy(native) + adjusted['paths']['/public']['get']['security'] = [{'Unrelated': []}] + adjusted['paths']['/mixed/{name}']['get']['description'] = '由调用者修改的文档' + assert docs_app.app.openapi_schema == snapshot + regenerated = docs_app.app.openapi() + assert regenerated['paths']['/public']['get']['security'] == [] + assert '由调用者修改的文档' not in regenerated['paths']['/mixed/{name}']['get']['description'] + + +def test_registering_docs_again_does_not_duplicate_conditional_authentication_notes(docs_app: SimpleNamespace) -> None: + before = deepcopy(docs_app.app.openapi()) + APIDocsUtil.custom_api_docs_router(docs_app.app) + assert docs_app.app.openapi() == before + + +@pytest.mark.asyncio +async def test_changed_exclusion_rules_restore_native_security_and_description(docs_app: SimpleNamespace) -> None: + pre_auth = PreAuthDependency([{'path': '/mutable/free'}]) + description = '原有接口描述必须保留。' + + @docs_app.app.get('/mutable/{name}', dependencies=[pre_auth], description=description) + async def mutable(name: str) -> dict: + return {'name': name} + + before = docs_app.app.openapi()['paths']['/mutable/{name}']['get'] + assert {} in before['security'] + assert before['description'].startswith(description) + assert before['x-login-auth']['mode'] == 'conditional' + original_cache = docs_app.app.openapi_schema + pre_auth.dependency.exclude_patterns.clear() + after = docs_app.app.openapi()['paths']['/mutable/{name}']['get'] + assert docs_app.app.openapi_schema is original_cache + assert after['security'] == [LOGIN_SECURITY] + assert after['description'] == description + assert 'x-login-auth' not in after + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + denied = await client.get('/mutable/free') + assert denied.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.parametrize('conditional', [False, True]) +def test_compound_security_from_openapi_extra_is_preserved(docs_app: SimpleNamespace, conditional: bool) -> None: + gateway = APIKeyHeader(name='X-Gateway-Key', scheme_name='GatewayKey') + audit = APIKeyHeader(name='X-Audit-Key', scheme_name='AuditKey') + + @docs_app.app.get('/security-definitions') + async def security_definitions( + gateway_key: Annotated[str, Depends(gateway)], audit_key: Annotated[str, Depends(audit)] + ) -> dict: + return {'gateway': gateway_key, 'audit': audit_key} + + path = '/compound/{name}' if conditional else '/compound' + description = '此接口还受网关凭证校验。' + additional_security = {'GatewayKey': [], 'AuditKey': []} + + @docs_app.app.get( + path, + dependencies=[PreAuthDependency([{'path': '/compound/free' if conditional else path}])], + description=description, + openapi_extra={'security': [{**LOGIN_SECURITY, **additional_security}], 'x-gateway': {'required': True}}, + ) + async def compound() -> dict: + return {'ok': True} + + operation = docs_app.app.openapi()['paths'][path]['get'] + assert additional_security in operation['security'] + assert all('GatewayKey' in requirement and 'AuditKey' in requirement for requirement in operation['security']) + assert operation['description'].startswith(description) + assert operation['x-gateway'] == {'required': True} + if conditional: + assert {**LOGIN_SECURITY, **additional_security} in operation['security'] + else: + assert operation['security'] == [additional_security] + assert operation['description'] == description + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('path', 'public_path', 'private_path', 'unmatched_ignore'), + [ + ('/files/{name}.json', '/files/free.json', '/files/private.json', '/files/private.txt'), + ('/v1.0/items/{name}', '/v1.0/items/free', '/v1.0/items/private', '/v1X0/items/private'), + ], +) +@pytest.mark.parametrize('ignore', ['none', 'matching', 'unrelated']) +async def test_embedded_parameters_and_literal_dots_preserve_full_exclusion( + docs_app: SimpleNamespace, path: str, public_path: str, private_path: str, unmatched_ignore: str, ignore: str +) -> None: + ignored_paths = [] if ignore == 'none' else [private_path if ignore == 'matching' else unmatched_ignore] + + @docs_app.app.get(path, dependencies=[PreAuthDependency([{'path': path, 'ignore_paths': ignored_paths}])]) + async def embedded(name: str) -> dict: + return {'name': name} + + security = _security(docs_app.app, path) + if ignore == 'matching': + assert LOGIN_SECURITY in security + assert {} in security + else: + assert security == [] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + public = await client.get(public_path) + private = await client.get(private_path) + assert public.json() == {'name': 'free'} + if ignore == 'matching': + assert private.json()['code'] == status.HTTP_401_UNAUTHORIZED + else: + assert private.json() == {'name': 'private'} + + +@pytest.mark.asyncio +async def test_modifying_documented_rules_cannot_change_runtime_authentication(docs_app: SimpleNamespace) -> None: + @docs_app.app.api_route( + '/documented/{name}', + methods=['GET', 'POST'], + dependencies=[ + PreAuthDependency( + [{'path': '/documented/{name}', 'methods': ['GET'], 'ignore_paths': ['/documented/private']}] + ) + ], + ) + async def documented(name: str) -> dict: + return {'name': name} + + schema = docs_app.app.openapi() + rule = schema['paths']['/documented/{name}']['get']['x-login-auth']['exclude_groups'][0][0] + rule['methods'].append('POST') + rule['ignore_paths'].clear() + fresh = docs_app.app.openapi()['paths']['/documented/{name}']['get']['x-login-auth']['exclude_groups'][0][0] + assert fresh['methods'] == ['GET'] + assert fresh['ignore_paths'] == ['/documented/private'] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + ignored = await client.get('/documented/private') + protected_method = await client.post('/documented/free') + public = await client.get('/documented/free') + assert ignored.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert protected_method.json()['code'] == status.HTTP_401_UNAUTHORIZED + assert public.json() == {'name': 'free'} + + +@pytest.mark.asyncio +async def test_explicit_anonymous_security_for_optional_api_key_is_preserved(docs_app: SimpleNamespace) -> None: + optional_key = APIKeyHeader(name='X-Optional-Key', scheme_name='OptionalKey', auto_error=False) + + @docs_app.app.get( + '/optional-key', + dependencies=[PreAuthDependency([{'path': '/optional-key'}])], + openapi_extra={'security': [{}]}, + ) + async def optional(key: Annotated[str | None, Depends(optional_key)]) -> dict: + return {'key': key} + + security = _security(docs_app.app, '/optional-key') + assert {} in security + assert all('OAuth2PasswordBearer' not in requirement for requirement in security) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + response = await client.get('/optional-key') + assert response.json() == {'key': None} + docs_app.resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_complex_rule_cannot_override_a_proven_empty_public_intersection(docs_app: SimpleNamespace) -> None: + @docs_app.app.get( + '/complex/{name}', + dependencies=[ + PreAuthDependency([{'path': '/complex/(a|b)'}]), + PreAuthDependency([{'path': '/complex/c'}]), + ], + ) + async def complex_rule(name: str) -> dict: + return {'name': name} + + assert _security(docs_app.app, '/complex/{name}') == [LOGIN_SECURITY] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + for name in ['a', 'b', 'c', 'other']: + response = await client.get(f'/complex/{name}') + assert response.json()['code'] == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_unresolved_complex_rules_do_not_add_an_unproven_anonymous_alternative(docs_app: SimpleNamespace) -> None: + @docs_app.app.get('/unresolved/{name}', dependencies=[PreAuthDependency([{'path': '/unresolved/(a|b)'}])]) + async def unresolved(name: str) -> dict: + return {'name': name} + + operation = docs_app.app.openapi()['paths']['/unresolved/{name}']['get'] + assert operation['security'] == [LOGIN_SECURITY] + assert operation['x-login-auth']['mode'] == 'conditional' + assert operation['x-login-auth']['static_analysis'] == 'unresolved' + assert '/unresolved/(a|b)' in json.dumps(operation['x-login-auth']) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=docs_app.app), base_url='http://test') as client: + public = await client.get('/unresolved/a') + protected = await client.get('/unresolved/private') + assert public.json() == {'name': 'a'} + assert protected.json()['code'] == status.HTTP_401_UNAUTHORIZED diff --git a/ruoyi-fastapi-backend/utils/openapi_util.py b/ruoyi-fastapi-backend/utils/openapi_util.py new file mode 100644 index 000000000..56e52dba4 --- /dev/null +++ b/ruoyi-fastapi-backend/utils/openapi_util.py @@ -0,0 +1,391 @@ +import re +from collections.abc import Iterator, Sequence +from typing import Any +from uuid import UUID + +from fastapi.routing import APIRoute, iter_route_contexts +from fastapi.security.base import SecurityBase +from starlette.convertors import Convertor, FloatConvertor, IntegerConvertor, StringConvertor, UUIDConvertor +from starlette.routing import BaseRoute + +from common.auth_policy import AuthPolicy, AuthRule +from module_admin.service.login_service import LoginService, oauth2_scheme + + +class OpenAPIUtil: + """ + OpenAPI认证声明工具类 + """ + + _MAX_PATH_STATES = 4096 + _CONDITIONAL_DESCRIPTION = ( + '免登录访问需在 x-login-auth 的每组规则中至少匹配一条排除规则,' + '且不属于该条规则的例外路径;其他安全要求仍需满足。' + ) + _UNRESOLVED_DESCRIPTION = '路径条件无法完整静态展开,安全声明保留登录要求;实际免登录范围以所列规则为准。' + + @classmethod + def adjust_security(cls, schema: dict[str, Any], routes: Sequence[BaseRoute]) -> dict[str, Any]: + """ + 根据前置认证规则调整各接口的登录安全声明 + + :param schema: 可修改的OpenAPI文档副本 + :param routes: 应用路由列表 + :return: 调整后的OpenAPI文档 + """ + for route, method in cls._iter_auth_routes(routes): + requirements = list( + AuthPolicy.iter_auth_dependencies(route.dependant, (LoginService.get_current_user, oauth2_scheme)) + ) + if not requirements or any(item is None for item in requirements): + continue + # 同名的其他安全依赖仍可能要求凭证,不能随宿主登录声明一起移除。 + if cls._has_external_login_scheme(route.dependant): + continue + operation = schema.get('paths', {}).get(route.path_format, {}).get(method.lower()) + if operation is None: + continue + mode = cls._get_auth_mode(route, method, requirements) + if mode == 'required': + continue + if mode != 'unknown': + cls._adjust_operation_security(operation, conditional=mode == 'conditional') + if mode in ('conditional', 'unknown'): + operation['x-login-auth'] = { + 'mode': 'conditional', + 'exclude_groups': [ + [ + { + 'path': rule['original_path'], + 'pattern': rule['pattern'].pattern, + 'methods': list(rule['methods']), + 'ignore_paths': list(rule['ignore_paths']), + } + for rule in pre_auth.exclude_patterns + ] + for pre_auth in requirements + ], + } + description = operation.get('description', '') + if cls._CONDITIONAL_DESCRIPTION not in description: + description = '\n\n'.join(item for item in (description, cls._CONDITIONAL_DESCRIPTION) if item) + if mode == 'unknown': + operation['x-login-auth']['static_analysis'] = 'unresolved' + if cls._UNRESOLVED_DESCRIPTION not in description: + description = f'{description}\n\n{cls._UNRESOLVED_DESCRIPTION}' + operation['description'] = description + return schema + + @staticmethod + def _iter_auth_routes(routes: Sequence[BaseRoute]) -> Iterator[tuple[Any, str]]: + """ + 获取不存在重复声明的路由 + + :param routes: 应用路由列表 + :return: 唯一路由及HTTP方法 + """ + first_routes = {} + conflicting_operations = set() + for route in iter_route_contexts(routes): + if not isinstance(route.original_route, APIRoute): + continue + for method in route.methods or []: + key = (route.path_format, method) + if key in first_routes: + conflicting_operations.add(key) + else: + first_routes[key] = route + + for key, route in first_routes.items(): + # 重复声明和隐藏路由可能使实际匹配与文档来源不同,保留原生声明。 + if key not in conflicting_operations: + yield route, key[1] + + @classmethod + def _has_external_login_scheme(cls, dependency: Any) -> bool: + """ + 判断是否存在与宿主登录同名的其他安全依赖 + + :param dependency: 路由依赖声明 + :return: 是否需要保留同名安全声明 + """ + call = dependency.call + if ( + isinstance(call, SecurityBase) + and call is not oauth2_scheme + and call.scheme_name == oauth2_scheme.scheme_name + ): + return True + return any(cls._has_external_login_scheme(child) for child in dependency.dependencies) + + @classmethod + def _adjust_operation_security(cls, operation: dict[str, Any], *, conditional: bool) -> None: + """ + 调整宿主登录要求并保留其他安全方案 + + :param operation: 接口文档 + :param conditional: 是否仅部分路径需要登录 + :return: None + """ + security = operation.get('security', []) + scheme = oauth2_scheme.scheme_name + login_scopes = None + remaining = [] + anonymous = any(not requirement for requirement in security) + for requirement in security: + if scheme in requirement: + login_scopes = requirement[scheme] + other = {key: value for key, value in requirement.items() if key != scheme} + if other and other not in remaining: + remaining.append(other) + if login_scopes is None: + return + # 文档原有的匿名选项可能来自可选的其他认证方案,应与新移除的登录要求区别处理。 + if anonymous: + remaining.append({}) + if not conditional: + operation['security'] = remaining + return + # 免登录的选项仍需满足其他安全要求,不能用空对象放行API Key等独立校验。 + alternatives = remaining or [{}] + operation['security'] = [{scheme: login_scopes, **requirement} for requirement in alternatives] + alternatives + + @classmethod + def _get_auth_mode(cls, route: Any, method: str, requirements: list[AuthRule]) -> str: + """ + 判断接口是否始终、从不或按路径参数要求登录 + + :param route: 包含最终路径和依赖的路由 + :param method: HTTP方法 + :param requirements: 各前置认证分支 + :return: required、public、conditional或无法完整分析的unknown + """ + if not route.param_convertors: + return 'public' if all(item.is_excluded_path(route.path, method) for item in requirements) else 'required' + ignored_paths = { + path for item in requirements for rule in cls._method_rules(item, method) for path in rule['ignore_paths'] + } + if all(cls._has_template_coverage(route, method, item) for item in requirements): + # 相同模板覆盖全部参数取值,例外路径需逐一检查。 + return ( + 'public' + if all( + not route.path_regex.fullmatch(path) or item.is_excluded_path(path, method) + for item in requirements + for path in ignored_paths + ) + else 'conditional' + ) + shape = cls._get_path_shape(route) + if shape is None: + # OpenAPI不能直接表达复杂路径正则的认证条件,保留完整规则供调用方判断。 + return 'required' if any(not cls._method_rules(item, method) for item in requirements) else 'unknown' + segments, convertors = shape + groups = [cls._get_rule_constraints(item, method, segments, convertors) for item in requirements] + if any(not rules and not unknown for rules, unknown in groups): + return 'required' + # 某条规则覆盖所有参数值时,只需检查其有限的例外路径是否被其他规则重新排除。 + if all( + segments in rules + and all( + not route.path_regex.fullmatch(path) or pre_auth.is_excluded_path(path, method) + for path in ignored_paths + ) + for pre_auth, (rules, _) in zip(requirements, groups, strict=True) + ): + return 'public' + anonymous = cls._has_public_path(segments, convertors, groups, ignored_paths, requirements, method) + if anonymous is None: + return 'unknown' + return 'conditional' if anonymous else 'required' + + @classmethod + def _has_template_coverage(cls, route: Any, method: str, pre_auth: AuthRule) -> bool: + """ + 判断同一模板的排除规则是否覆盖全部参数值 + + :param route: 接口路由 + :param method: HTTP方法 + :param pre_auth: 前置认证依赖 + :return: 不考虑有限例外时是否覆盖所有参数值 + """ + supported = {StringConvertor.regex, IntegerConvertor.regex, FloatConvertor.regex, UUIDConvertor.regex} + if any(convertor.regex not in supported for convertor in route.param_convertors.values()): + return False + parameters = re.findall(r'\{[^}]+\}', route.path) + if len(parameters) != len(route.param_convertors): + return False + skeleton = re.sub(r'\{[^}]+\}', '{}', route.path) + # 非空单段参数可由[^/]+覆盖;相同位置的点号也能匹配固定的点号,不改变模板结构。 + if re.search(r'[\^$*+?{}\[\]\\|()]', skeleton.replace('{}', '')): + return False + rules = cls._method_rules(pre_auth, method) + return any(re.sub(r'\{[^}]+\}', '{}', rule['original_path']) == skeleton for rule in rules) + + @staticmethod + def _method_rules(pre_auth: AuthRule, method: str) -> list[dict[str, Any]]: + """ + 获取适用于当前HTTP方法的排除规则 + + :param pre_auth: 前置认证依赖 + :param method: HTTP方法 + :return: 排除规则列表 + """ + return [rule for rule in pre_auth.exclude_patterns if not rule['methods'] or method in rule['methods']] + + @staticmethod + def _get_path_shape(route: Any) -> tuple[tuple[str | None, ...], dict[int, Convertor]] | None: + """ + 将普通路径模板拆分为固定段和单段参数 + + :param route: 接口路由 + :return: 路径段及参数转换器,复杂模板返回None + """ + segments = [] + convertors = {} + supported = {StringConvertor.regex, IntegerConvertor.regex, FloatConvertor.regex, UUIDConvertor.regex} + for index, segment in enumerate(route.path.split('/')): + parameter = re.fullmatch(r'\{([^{}:]+)(?::[^{}]+)?\}', segment) + if parameter: + convertor = route.param_convertors[parameter[1]] + if convertor.regex not in supported: + return None + convertors[index] = convertor + segments.append(None) + elif '{' in segment or '}' in segment: + return None + else: + segments.append(segment) + return tuple(segments), convertors + + @classmethod + def _get_rule_constraints( + cls, + pre_auth: AuthRule, + method: str, + segments: tuple[str | None, ...], + convertors: dict[int, Convertor], + ) -> tuple[list[tuple[str | None, ...]], bool]: + """ + 将排除规则转换为路径参数的取值约束 + + :param pre_auth: 前置认证依赖 + :param method: HTTP方法 + :param segments: 路径模板 + :param convertors: 参数转换器 + :return: 普通规则约束和是否包含复杂正则 + """ + constraints = [] + unknown = False + for rule in cls._method_rules(pre_auth, method): + parts = rule['original_path'].split('/') + if any(not re.fullmatch(r'\{[^{}]+\}', part) and re.search(r'[.^$*+?{}\[\]\\|()]', part) for part in parts): + unknown = True + continue + if len(parts) != len(segments): + continue + values = list(segments) + for index, part in enumerate(parts): + if re.fullmatch(r'\{[^{}]+\}', part): + if values[index] == '': + break + elif values[index] is None: + if not re.fullmatch(convertors[index].regex, part): + break + values[index] = part + elif values[index] != part: + break + else: + constraint = tuple(values) + if constraint not in constraints: + constraints.append(constraint) + return constraints, unknown + + @classmethod + def _has_public_path( + cls, + segments: tuple[str | None, ...], + convertors: dict[int, Convertor], + groups: list[tuple[list[tuple[str | None, ...]], bool]], + ignored_paths: set[str], + requirements: list[AuthRule], + method: str, + ) -> bool | None: + """ + 判断各前置认证分支的公开路径是否存在交集 + + :param segments: 路径模板 + :param convertors: 参数转换器 + :param groups: 各分支的参数约束 + :param ignored_paths: 规则中的例外路径 + :param requirements: 前置认证分支 + :param method: HTTP方法 + :return: 是否存在公开路径,复杂规则或分析上限返回None + """ + choices = sorted([rules + ([segments] if unknown else []) for rules, unknown in groups], key=len) + visited = set() + limited = False + unresolved = False + has_unknown_rules = any(unknown for _, unknown in groups) + + def visit(index: int, values: tuple[str | None, ...]) -> bool: + nonlocal limited, unresolved + state = (index, values) + if state in visited: + return False + if len(visited) >= cls._MAX_PATH_STATES: + limited = True + return False + visited.add(state) + if index == len(choices): + path = cls._get_candidate_path(values, convertors, ignored_paths) + if all(item.is_excluded_path(path, method) for item in requirements): + return True + # 参数已全部固定时,实际匹配结果足以证明该分支没有公开路径。 + if has_unknown_rules and any(value is None for value in values): + unresolved = True + return False + for constraint in choices[index]: + combined = [] + for current, required in zip(values, constraint, strict=True): + if current is not None and required is not None and current != required: + break + combined.append(current if current is not None else required) + else: + if visit(index + 1, tuple(combined)): + return True + return False + + if visit(0, segments): + return True + return None if limited or unresolved else False + + @staticmethod + def _get_candidate_path( + segments: tuple[str | None, ...], convertors: dict[int, Convertor], ignored_paths: set[str] + ) -> str: + """ + 为未固定的参数选择不落入有限例外列表的值 + + :param segments: 已合并的路径参数约束 + :param convertors: 参数转换器 + :param ignored_paths: 例外路径 + :return: 用于检查规则交集的实际路径 + """ + values = list(segments) + ignored_segments = [path.split('/') for path in ignored_paths] + for index, convertor in convertors.items(): + if values[index] is not None: + continue + excluded = {parts[index] for parts in ignored_segments if len(parts) == len(values)} + for number in range(len(excluded) + 1): + if convertor.regex == StringConvertor.regex: + candidate = f'__openapi_{number}__' + elif convertor.regex == UUIDConvertor.regex: + candidate = str(UUID(int=number)) + else: + candidate = str(number) + if candidate not in excluded: + values[index] = candidate + break + return '/'.join(values) diff --git a/ruoyi-fastapi-backend/utils/server_util.py b/ruoyi-fastapi-backend/utils/server_util.py index cf99bc71f..a3455a217 100644 --- a/ruoyi-fastapi-backend/utils/server_util.py +++ b/ruoyi-fastapi-backend/utils/server_util.py @@ -4,11 +4,12 @@ import socket import uuid from collections.abc import Callable +from copy import deepcopy +from typing import Any import psutil from fastapi import FastAPI, Request, applications from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html -from fastapi.openapi.utils import get_openapi from fastapi.responses import HTMLResponse, JSONResponse from redis import asyncio as aioredis @@ -140,6 +141,18 @@ def custom_api_docs_router( :param swagger_favicon_url: Swagger UI要使用的favicon的URL :return: """ + original_openapi = app.openapi + + def openapi() -> dict[str, Any]: + # 生成文档时再加载认证规则,避免服务器工具与登录服务循环引用。 + from utils.openapi_util import OpenAPIUtil # noqa: PLC0415 + + # 保留FastAPI原有缓存,公开规则调整不修改缓存中的安全声明。 + schema = deepcopy(original_openapi()) + OpenAPIUtil.adjust_security(schema, app.routes) + return schema + + app.openapi = openapi async def custom_openapi(request: Request) -> JSONResponse: return await cls._custom_openapi(app) @@ -166,22 +179,7 @@ async def _custom_openapi(cls, app: FastAPI) -> JSONResponse: :param app: FastAPI对象 :return: openapi的json响应 """ - openapi_schema = get_openapi( - title=app.title, - version=app.version, - openapi_version=app.openapi_version, - summary=app.summary, - description=app.description, - terms_of_service=app.terms_of_service, - contact=app.contact, - license_info=app.license_info, - routes=app.routes, - webhooks=app.webhooks.routes, - tags=app.openapi_tags, - separate_input_output_schemas=app.separate_input_output_schemas, - external_docs=app.openapi_external_docs, - ) - return JSONResponse(openapi_schema) + return JSONResponse(app.openapi()) @classmethod async def _custom_redoc( From 738129e07eb2dae4399f19301f5b29dad7335f09 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Thu, 29 Oct 2026 11:11:06 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E7=94=A8=E6=88=B7=E7=BC=93=E5=AD=98=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-fastapi-backend/.env.dev | 4 + ruoyi-fastapi-backend/.env.dockermy | 4 + ruoyi-fastapi-backend/.env.dockerpg | 4 + ruoyi-fastapi-backend/.env.prod | 4 + ruoyi-fastapi-backend/config/env.py | 2 + .../test_current_user_cache_settings.py | 49 ++ .../utils/test_current_user_cache_util.py | 587 ++++++++++++++++++ ruoyi-fastapi-backend/utils/cache_util.py | 291 +++++++++ 8 files changed, 945 insertions(+) create mode 100644 ruoyi-fastapi-backend/tests/config/test_current_user_cache_settings.py create mode 100644 ruoyi-fastapi-backend/tests/utils/test_current_user_cache_util.py create mode 100644 ruoyi-fastapi-backend/utils/cache_util.py diff --git a/ruoyi-fastapi-backend/.env.dev b/ruoyi-fastapi-backend/.env.dev index aff2ea99b..e11547f4e 100644 --- a/ruoyi-fastapi-backend/.env.dev +++ b/ruoyi-fastapi-backend/.env.dev @@ -21,6 +21,10 @@ APP_WORKERS = 1 APP_IP_LOCATION_QUERY = true # 应用是否允许账号同时登录 APP_SAME_TIME_LOGIN = true +# 应用是否缓存当前用户资料及权限 +APP_CURRENT_USER_CACHE_ENABLED = true +# 当前用户缓存最大有效期,单位秒,范围1至60,访问和令牌续期不会延长 +APP_CURRENT_USER_CACHE_TTL_SECONDS = 5 # 应用是否为演示模式 APP_DEMO_MODE = false # 应用是否禁用Swagger文档 diff --git a/ruoyi-fastapi-backend/.env.dockermy b/ruoyi-fastapi-backend/.env.dockermy index 112f54fd8..ddce0fa84 100644 --- a/ruoyi-fastapi-backend/.env.dockermy +++ b/ruoyi-fastapi-backend/.env.dockermy @@ -21,6 +21,10 @@ APP_WORKERS = 1 APP_IP_LOCATION_QUERY = true # 应用是否允许账号同时登录 APP_SAME_TIME_LOGIN = true +# 应用是否缓存当前用户资料及权限 +APP_CURRENT_USER_CACHE_ENABLED = true +# 当前用户缓存最大有效期,单位秒,范围1至60,访问和令牌续期不会延长 +APP_CURRENT_USER_CACHE_TTL_SECONDS = 5 # 应用是否为演示模式 APP_DEMO_MODE = false # 应用是否禁用Swagger文档 diff --git a/ruoyi-fastapi-backend/.env.dockerpg b/ruoyi-fastapi-backend/.env.dockerpg index 5c926dd50..c81575a86 100644 --- a/ruoyi-fastapi-backend/.env.dockerpg +++ b/ruoyi-fastapi-backend/.env.dockerpg @@ -23,6 +23,10 @@ APP_IP_LOCATION_QUERY = true APP_DEMO_MODE = false # 应用是否允许账号同时登录 APP_SAME_TIME_LOGIN = true +# 应用是否缓存当前用户资料及权限 +APP_CURRENT_USER_CACHE_ENABLED = true +# 当前用户缓存最大有效期,单位秒,范围1至60,访问和令牌续期不会延长 +APP_CURRENT_USER_CACHE_TTL_SECONDS = 5 # 应用是否禁用Swagger文档 APP_DISABLE_SWAGGER = true # 应用是否禁用ReDoc文档 diff --git a/ruoyi-fastapi-backend/.env.prod b/ruoyi-fastapi-backend/.env.prod index 7b800da1f..1b11e6f6d 100644 --- a/ruoyi-fastapi-backend/.env.prod +++ b/ruoyi-fastapi-backend/.env.prod @@ -21,6 +21,10 @@ APP_WORKERS = 1 APP_IP_LOCATION_QUERY = true # 应用是否允许账号同时登录 APP_SAME_TIME_LOGIN = true +# 应用是否缓存当前用户资料及权限 +APP_CURRENT_USER_CACHE_ENABLED = true +# 当前用户缓存最大有效期,单位秒,范围1至60,访问和令牌续期不会延长 +APP_CURRENT_USER_CACHE_TTL_SECONDS = 5 # 应用是否为演示模式 APP_DEMO_MODE = false # 应用是否禁用Swagger文档 diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index e3ed165d1..85a82002c 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -32,6 +32,8 @@ class AppSettings(BaseSettings): app_workers: int = 1 app_ip_location_query: bool = True app_same_time_login: bool = True + app_current_user_cache_enabled: bool = True + app_current_user_cache_ttl_seconds: int = Field(default=5, ge=1, le=60) app_demo_mode: bool = False app_disable_swagger: bool = False app_disable_redoc: bool = False diff --git a/ruoyi-fastapi-backend/tests/config/test_current_user_cache_settings.py b/ruoyi-fastapi-backend/tests/config/test_current_user_cache_settings.py new file mode 100644 index 000000000..5213b2965 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/config/test_current_user_cache_settings.py @@ -0,0 +1,49 @@ +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from config.env import AppSettings + +CACHE_LIFETIME_SECONDS = 3 +DEFAULT_CACHE_LIFETIME_SECONDS = 5 + + +@pytest.mark.parametrize('seconds', [0, -1, 61]) +def test_current_user_cache_rejects_invalid_lifetime(seconds: int) -> None: + """缓存必须短时过期,不能配置为永久有效或非法有效期。""" + with pytest.raises(ValidationError, match='app_current_user_cache_ttl_seconds'): + AppSettings(app_current_user_cache_ttl_seconds=seconds) + + +def test_current_user_cache_is_enabled_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + """未显式配置时启用缓存,并使用短时硬过期。""" + monkeypatch.delenv('APP_CURRENT_USER_CACHE_ENABLED', raising=False) + monkeypatch.delenv('APP_CURRENT_USER_CACHE_TTL_SECONDS', raising=False) + + settings = AppSettings() + + assert settings.app_current_user_cache_enabled is True + assert settings.app_current_user_cache_ttl_seconds == DEFAULT_CACHE_LIFETIME_SECONDS + + +@pytest.mark.parametrize('enabled', [True, False]) +def test_current_user_cache_environment_configuration(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: + """缓存开关和有效期可通过环境变量配置。""" + monkeypatch.setenv('APP_CURRENT_USER_CACHE_ENABLED', str(enabled).lower()) + monkeypatch.setenv('APP_CURRENT_USER_CACHE_TTL_SECONDS', str(CACHE_LIFETIME_SECONDS)) + + settings = AppSettings() + + assert settings.app_current_user_cache_enabled is enabled + assert settings.app_current_user_cache_ttl_seconds == CACHE_LIFETIME_SECONDS + + +@pytest.mark.parametrize('environment', ['dev', 'prod', 'dockermy', 'dockerpg']) +def test_environment_templates_include_current_user_cache_settings(environment: str) -> None: + """各部署环境明确提供缓存开关及默认硬过期时间。""" + environment_file = Path(__file__).resolve().parents[2] / f'.env.{environment}' + content = environment_file.read_text() + + assert 'APP_CURRENT_USER_CACHE_ENABLED = true' in content + assert 'APP_CURRENT_USER_CACHE_TTL_SECONDS = 5' in content diff --git a/ruoyi-fastapi-backend/tests/utils/test_current_user_cache_util.py b/ruoyi-fastapi-backend/tests/utils/test_current_user_cache_util.py new file mode 100644 index 000000000..ade8b31a0 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/utils/test_current_user_cache_util.py @@ -0,0 +1,587 @@ +import asyncio +import json +import shutil +import subprocess +import tempfile +import time +from collections.abc import AsyncIterator, Iterator +from dataclasses import replace +from pathlib import Path +from unittest.mock import AsyncMock + +import anyio +import pytest +import pytest_asyncio +from redis.asyncio import Redis +from redis.asyncio.connection import ConnectionPool +from redis.exceptions import ConnectionError as RedisConnectionError +from redis.exceptions import ResponseError +from redis.exceptions import TimeoutError as RedisTimeoutError +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import create_async_engine + +from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel +from utils.cache_util import CurrentUserCacheTicket, CurrentUserCacheUtil + +USER_ID = 2 +OTHER_USER_ID = 3 +TTL_SECONDS = 5 +TTL_MS = TTL_SECONDS * 1000 +NAMESPACE = 'current-user-cache-test' + + +@pytest.fixture(scope='module') +def redis_socket() -> Iterator[str]: + """启动只监听临时Unix socket的Redis,不访问开发环境Redis数据。""" + executable = shutil.which('redis-server') + if executable is None: + pytest.skip('真实缓存脚本测试需要redis-server') + with tempfile.TemporaryDirectory(prefix='user-cache-', dir='/tmp') as directory: + socket_path = str(Path(directory) / 'redis.sock') + process = subprocess.Popen( + [ + executable, + '--port', + '0', + '--unixsocket', + socket_path, + '--save', + '', + '--appendonly', + 'no', + '--dir', + directory, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + for _attempt in range(100): + if process.poll() is not None: + pytest.fail('隔离Redis进程启动失败,请检查Unix socket权限') + if Path(socket_path).exists(): + break + time.sleep(0.01) + else: + pytest.fail('隔离Redis进程未创建Unix socket') + yield socket_path + finally: + process.terminate() + process.wait(timeout=5) + + +@pytest_asyncio.fixture +async def cache_redis(redis_socket: str) -> AsyncIterator[Redis]: + """每项测试使用独立客户端并清空专属测试实例。""" + redis = Redis(unix_socket_path=redis_socket, decode_responses=True, socket_timeout=1) + try: + for _attempt in range(10): + try: + await asyncio.wait_for(redis.ping(), timeout=0.2) + break + except (RedisConnectionError, asyncio.TimeoutError): + await asyncio.sleep(0.01) + else: + pytest.fail('隔离Redis测试实例未能启动') + await redis.flushdb() + yield redis + finally: + await redis.aclose() + + +def user_model(user_id: int = USER_ID, permission: str = 'demo:read') -> CurrentUserModel: + return CurrentUserModel( + permissions=[permission], + roles=['reader'], + user=UserInfoModel(userId=user_id, userName='reader', status='0', delFlag='0'), + appTimezone='Asia/Shanghai', + pwdChrtype='2', + isDefaultModifyPwd=True, + isPasswordExpired=True, + ) + + +async def prime_cache(redis: Redis, user_id: int = USER_ID) -> CurrentUserModel: + user = user_model(user_id) + cached, ticket = await CurrentUserCacheUtil.read(redis, NAMESPACE, user_id, TTL_SECONDS) + assert cached is None + assert ticket is not None + assert await CurrentUserCacheUtil.write(redis, NAMESPACE, user_id, user, ticket) + return user + + +@pytest.mark.asyncio +async def test_round_trip_isolates_models_and_omits_password_and_dynamic_fields(cache_redis: Redis) -> None: + original = await prime_cache(cache_redis) + cached, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + assert cached is not None + assert ticket is None + assert cached.permissions == original.permissions + assert cached.user == original.user + assert cached.pwd_chrtype == '0' + assert cached.is_default_modify_pwd is False + assert cached.is_password_expired is False + cached.permissions.clear() + cached.user.nick_name = 'changed' + second, _ = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + assert second.permissions == original.permissions + assert second.user.nick_name is None + snapshot = await cache_redis.hget(CurrentUserCacheUtil._keys(NAMESPACE, USER_ID)[2], 'payload') + payload = json.loads(snapshot) + assert set(payload) == {'permissions', 'roles', 'user'} + assert 'password' not in payload['user'] + + +@pytest.mark.asyncio +async def test_password_hash_is_never_serialized(cache_redis: Redis) -> None: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + user = user_model() + user.user.password = 'secret-hash' + assert await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user, ticket) + payload = await cache_redis.hget(CurrentUserCacheUtil._keys(NAMESPACE, USER_ID)[2], 'payload') + assert 'secret-hash' not in payload + assert 'password' not in json.loads(payload)['user'] + cached, _ = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + assert cached.user.password is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('shared', [False, True]) +async def test_invalidation_rejects_old_query_results(cache_redis: Redis, shared: bool) -> None: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + assert await CurrentUserCacheUtil.invalidate(cache_redis, NAMESPACE, None if shared else {USER_ID}) + assert not await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user_model(), ticket) + cached, replacement = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + assert cached is None + assert (replacement.global_epoch, replacement.user_epoch) != (ticket.global_epoch, ticket.user_epoch) + + +@pytest.mark.asyncio +async def test_concurrent_queries_cannot_repopulate_after_invalidation(cache_redis: Redis) -> None: + query_count = 8 + prepared = [] + all_started = asyncio.Event() + complete_queries = asyncio.Event() + + async def query_and_fill() -> bool: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + prepared.append(ticket) + if len(prepared) == query_count: + all_started.set() + await complete_queries.wait() + return await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user_model(), ticket) + + tasks = [asyncio.create_task(query_and_fill()) for _ in range(query_count)] + try: + await asyncio.wait_for(all_started.wait(), timeout=2) + assert await CurrentUserCacheUtil.invalidate(cache_redis, NAMESPACE, {USER_ID}) + complete_queries.set() + assert not any(await asyncio.gather(*tasks)) + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS))[0] is None + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_user_invalidation_preserves_other_users_and_global_invalidates_all(cache_redis: Redis) -> None: + await prime_cache(cache_redis, USER_ID) + await prime_cache(cache_redis, OTHER_USER_ID) + assert await CurrentUserCacheUtil.invalidate(cache_redis, NAMESPACE, {USER_ID}) + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS))[0] is None + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, OTHER_USER_ID, TTL_SECONDS))[0] is not None + assert await CurrentUserCacheUtil.invalidate(cache_redis, NAMESPACE) + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, OTHER_USER_ID, TTL_SECONDS))[0] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('key_index', [0, 1]) +async def test_missing_epoch_never_restores_existing_snapshot(cache_redis: Redis, key_index: int) -> None: + await prime_cache(cache_redis) + keys = CurrentUserCacheUtil._keys(NAMESPACE, USER_ID) + previous = await cache_redis.get(keys[key_index]) + await cache_redis.delete(keys[key_index]) + cached, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + assert cached is None + assert ticket is not None + assert await cache_redis.get(keys[key_index]) != previous + + +@pytest.mark.asyncio +async def test_fixed_deadline_is_not_extended_by_slow_fill_or_reads(cache_redis: Redis) -> None: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + await asyncio.sleep(0.03) + assert await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user_model(), ticket) + snapshot_key = CurrentUserCacheUtil._keys(NAMESPACE, USER_ID)[2] + expiry = await cache_redis.pexpiretime(snapshot_key) + assert expiry == ticket.deadline_ms + assert await cache_redis.pttl(snapshot_key) < TTL_MS + for _ in range(3): + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS))[0] is not None + assert await cache_redis.pexpiretime(snapshot_key) == expiry + + +@pytest.mark.asyncio +async def test_user_epoch_has_idle_expiry_without_extending_snapshot_deadline(cache_redis: Redis) -> None: + await prime_cache(cache_redis) + global_key, user_key, snapshot_key = CurrentUserCacheUtil._keys(NAMESPACE, USER_ID) + epoch_ttl_ms = CurrentUserCacheUtil._USER_EPOCH_TTL_SECONDS * 1000 + assert 0 < await cache_redis.pttl(user_key) <= epoch_ttl_ms + assert await cache_redis.ttl(global_key) == -1 + deadline = await cache_redis.pexpiretime(snapshot_key) + await cache_redis.pexpire(user_key, TTL_MS) + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS))[0] is not None + assert TTL_MS < await cache_redis.pttl(user_key) <= epoch_ttl_ms + assert await cache_redis.pexpiretime(snapshot_key) == deadline + + +@pytest.mark.asyncio +async def test_invalidation_expires_user_epochs_but_keeps_global_epoch(cache_redis: Redis) -> None: + assert await CurrentUserCacheUtil.invalidate(cache_redis, NAMESPACE, {USER_ID, OTHER_USER_ID}) + for user_id in (USER_ID, OTHER_USER_ID): + user_key = CurrentUserCacheUtil._keys(NAMESPACE, user_id)[1] + assert 0 < await cache_redis.ttl(user_key) <= CurrentUserCacheUtil._USER_EPOCH_TTL_SECONDS + assert await CurrentUserCacheUtil.invalidate(cache_redis, NAMESPACE) + assert await cache_redis.ttl(CurrentUserCacheUtil._keys(NAMESPACE, USER_ID)[0]) == -1 + + +@pytest.mark.asyncio +async def test_query_past_its_original_deadline_cannot_fill(cache_redis: Redis) -> None: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + expired = replace(ticket, created_at_ms=ticket.created_at_ms - TTL_MS, deadline_ms=ticket.created_at_ms - 1) + assert not await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user_model(), expired) + assert await cache_redis.exists(CurrentUserCacheUtil._keys(NAMESPACE, USER_ID)[2]) == 0 + + +@pytest.mark.asyncio +async def test_late_older_query_cannot_replace_newer_snapshot(cache_redis: Redis) -> None: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + older = replace(ticket, created_at_ms=ticket.created_at_ms - 100) + assert await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user_model(permission='new'), ticket) + assert not await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user_model(permission='old'), older) + cached, _ = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + assert cached.permissions == ['new'] + + +@pytest.mark.asyncio +async def test_shorter_configured_ttl_does_not_accept_previously_longer_snapshot(cache_redis: Redis) -> None: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + old = replace(ticket, created_at_ms=ticket.created_at_ms - TTL_MS) + assert await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user_model(), old) + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS * 2))[0] is not None + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS))[0] is None + + +@pytest.mark.asyncio +async def test_restored_expired_payload_is_not_accepted_even_if_redis_key_has_new_ttl(cache_redis: Redis) -> None: + await prime_cache(cache_redis) + snapshot_key = CurrentUserCacheUtil._keys(NAMESPACE, USER_ID)[2] + await cache_redis.hset(snapshot_key, 'deadline', 1) + await cache_redis.expire(snapshot_key, TTL_SECONDS) + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS))[0] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('damage', ['json', 'user_id', 'password', 'disabled', 'deleted', 'null']) +async def test_damaged_snapshot_is_not_used(cache_redis: Redis, damage: str) -> None: + await prime_cache(cache_redis) + key = CurrentUserCacheUtil._keys(NAMESPACE, USER_ID)[2] + payload = json.loads(await cache_redis.hget(key, 'payload')) + if damage == 'json': + serialized = '{' + else: + if damage == 'user_id': + payload['user']['userId'] = OTHER_USER_ID + elif damage == 'password': + payload['user']['password'] = 'unwanted-hash' + elif damage == 'disabled': + payload['user']['status'] = '1' + elif damage == 'deleted': + payload['user']['delFlag'] = '2' + elif damage == 'null': + payload['user'] = None + serialized = json.dumps(payload) + await cache_redis.hset(key, 'payload', serialized) + cached, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + assert cached is None + assert ticket is not None + assert await CurrentUserCacheUtil.write(cache_redis, NAMESPACE, USER_ID, user_model(), ticket) + + +@pytest.mark.asyncio +async def test_namespaces_and_users_do_not_share_snapshots(cache_redis: Redis) -> None: + await prime_cache(cache_redis) + assert (await CurrentUserCacheUtil.read(cache_redis, 'another-database', USER_ID, TTL_SECONDS))[0] is None + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, OTHER_USER_ID, TTL_SECONDS))[0] is None + + +@pytest.mark.asyncio +async def test_empty_invalidation_does_not_invalidate_other_users(cache_redis: Redis) -> None: + await prime_cache(cache_redis) + assert await CurrentUserCacheUtil.invalidate(cache_redis, NAMESPACE, set()) + assert (await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS))[0] is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('error_type', [RedisConnectionError, RedisTimeoutError, ResponseError]) +async def test_cache_failure_returns_to_database_without_claiming_success(error_type: type[Exception]) -> None: + redis = AsyncMock(spec=Redis) + redis.eval.side_effect = error_type('unavailable') + assert await CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, TTL_SECONDS) == (None, None) + assert not await CurrentUserCacheUtil.invalidate(redis, NAMESPACE) + ticket = CurrentUserCacheTicket('global', 'user', 0, TTL_MS) + assert not await CurrentUserCacheUtil.write(redis, NAMESPACE, USER_ID, user_model(), ticket) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('result', [None, 1, [], ['global', 'user', '1', '2'], ['global', 'user', None, '2', '']]) +async def test_malformed_redis_response_falls_back_to_database(result: object) -> None: + redis = AsyncMock(spec=Redis) + redis.eval.return_value = result + assert await CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, TTL_SECONDS) == (None, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['read', 'write', 'invalidate']) +async def test_slow_cache_operation_is_bounded_and_cancelled( + cache_redis: Redis, operation: str, monkeypatch: pytest.MonkeyPatch +) -> None: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + stopped = asyncio.Event() + + async def slow_eval(*args: object) -> None: + try: + await asyncio.Event().wait() + finally: + stopped.set() + + redis = AsyncMock(spec=Redis) + redis.eval.side_effect = slow_eval + monkeypatch.setattr(CurrentUserCacheUtil, '_OPERATION_TIMEOUT_SECONDS', 0.01) + if operation == 'read': + assert await CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, TTL_SECONDS) == (None, None) + elif operation == 'write': + assert not await CurrentUserCacheUtil.write(redis, NAMESPACE, USER_ID, user_model(), ticket) + else: + assert not await CurrentUserCacheUtil.invalidate(redis, NAMESPACE) + assert stopped.is_set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['read', 'write', 'invalidate']) +async def test_cancellation_is_propagated(cache_redis: Redis, operation: str) -> None: + _, ticket = await CurrentUserCacheUtil.read(cache_redis, NAMESPACE, USER_ID, TTL_SECONDS) + redis = AsyncMock(spec=Redis) + redis.eval.side_effect = asyncio.CancelledError + with pytest.raises(asyncio.CancelledError): + if operation == 'read': + await CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, TTL_SECONDS) + elif operation == 'write': + await CurrentUserCacheUtil.write(redis, NAMESPACE, USER_ID, user_model(), ticket) + else: + await CurrentUserCacheUtil.invalidate(redis, NAMESPACE) + + +@pytest.mark.asyncio +async def test_cancelling_caller_stops_pending_redis_operation() -> None: + started = asyncio.Event() + stopped = asyncio.Event() + + async def blocking_eval(*args: object) -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + stopped.set() + + redis = AsyncMock(spec=Redis) + redis.eval.side_effect = blocking_eval + task = asyncio.create_task(CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, TTL_SECONDS)) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert stopped.is_set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['read', 'write', 'invalidate']) +@pytest.mark.parametrize('cancellation', ['task_group', 'deadline']) +async def test_anyio_parent_cancellation_is_not_converted_to_cache_fallback(operation: str, cancellation: str) -> None: + started = anyio.Event() + stopped = anyio.Event() + results = [] + + async def blocking_eval(*args: object) -> None: + started.set() + try: + await anyio.sleep_forever() + finally: + stopped.set() + + redis = AsyncMock(spec=Redis) + redis.eval.side_effect = blocking_eval + ticket = CurrentUserCacheTicket('global', 'user', 0, TTL_MS) + + async def run_operation() -> None: + if operation == 'read': + result = await CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, TTL_SECONDS) + elif operation == 'write': + result = await CurrentUserCacheUtil.write(redis, NAMESPACE, USER_ID, user_model(), ticket) + else: + result = await CurrentUserCacheUtil.invalidate(redis, NAMESPACE) + results.append(result) + + if cancellation == 'task_group': + async with anyio.create_task_group() as group: + group.start_soon(run_operation) + await started.wait() + group.cancel_scope.cancel() + else: + with pytest.raises(TimeoutError), anyio.fail_after(0.01): + await run_operation() + + assert stopped.is_set() + assert results == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['read', 'write', 'invalidate']) +@pytest.mark.parametrize('cancellation', ['timeout', 'anyio', 'task', 'repeated_task', 'late_anyio', 'late_task']) +async def test_cancellation_waits_for_redis_connection_release( # noqa: PLR0915 + operation: str, cancellation: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """使用真实连接池模拟命令等待及归还连接时的锁竞争。""" + pool = ConnectionPool(max_connections=1, decode_responses=True) + monkeypatch.setattr(pool, 'ensure_connection', AsyncMock()) + redis = Redis(connection_pool=pool) + started = asyncio.Event() + cleanup_started = asyncio.Event() + cancelled = asyncio.Event() + results = [] + request_scope = anyio.CancelScope() + timed_out = cancellation in ('timeout', 'late_anyio', 'late_task') + monkeypatch.setattr(CurrentUserCacheUtil, '_OPERATION_TIMEOUT_SECONDS', 0.02 if timed_out else 1) + + async def blocking_command(*args: object, **kwargs: object) -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + cleanup_started.set() + + monkeypatch.setattr(redis, '_send_command_parse_response', blocking_command) + ticket = CurrentUserCacheTicket('global', 'user', 0, TTL_MS) + + async def run_operation() -> None: + with request_scope: + try: + if operation == 'read': + result = await CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, TTL_SECONDS) + elif operation == 'write': + result = await CurrentUserCacheUtil.write(redis, NAMESPACE, USER_ID, user_model(), ticket) + else: + result = await CurrentUserCacheUtil.invalidate(redis, NAMESPACE) + results.append(result) + except asyncio.CancelledError: + cancelled.set() + raise + + task = asyncio.create_task(run_operation()) + try: + await asyncio.wait_for(started.wait(), timeout=1) + async with pool._lock: + if cancellation == 'anyio': + request_scope.cancel() + elif cancellation in ('task', 'repeated_task'): + task.cancel() + await asyncio.wait_for(cleanup_started.wait(), timeout=1) + if cancellation == 'late_anyio': + request_scope.cancel() + elif cancellation in ('repeated_task', 'late_task'): + task.cancel() + await asyncio.sleep(0) + assert not task.done() + assert len(pool._in_use_connections) == 1 + + if cancellation in ('task', 'repeated_task', 'late_task'): + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=1) + else: + await asyncio.wait_for(task, timeout=1) + assert len(pool._in_use_connections) == 0 + assert len(pool._available_connections) == 1 + if cancellation == 'timeout': + assert results == [(None, None) if operation == 'read' else False] + assert not cancelled.is_set() + else: + assert results == [] + assert cancelled.is_set() + monkeypatch.setattr(redis, '_send_command_parse_response', AsyncMock(return_value=True)) + assert await redis.ping() + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await redis.aclose() + await pool.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('cancellation', ['timeout', 'task']) +async def test_redis_cleanup_error_does_not_replace_timeout_or_cancellation( + cancellation: str, monkeypatch: pytest.MonkeyPatch +) -> None: + started = asyncio.Event() + + async def failing_cleanup(*args: object) -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + raise RuntimeError('cleanup failed') + + redis = AsyncMock(spec=Redis) + redis.eval.side_effect = failing_cleanup + monkeypatch.setattr(CurrentUserCacheUtil, '_OPERATION_TIMEOUT_SECONDS', 0.02 if cancellation == 'timeout' else 1) + task = asyncio.create_task(CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, TTL_SECONDS)) + await asyncio.wait_for(started.wait(), timeout=1) + if cancellation == 'task': + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + assert await task == (None, None) + + +@pytest.mark.asyncio +async def test_disabled_cache_never_accesses_redis() -> None: + redis = AsyncMock(spec=Redis) + assert await CurrentUserCacheUtil.read(redis, NAMESPACE, USER_ID, 0) == (None, None) + redis.eval.assert_not_called() + + +def test_namespace_is_stable_for_engine_and_connection_and_unknown_bind_bypasses() -> None: + engine = create_engine('sqlite://') + try: + with engine.connect() as connection: + assert CurrentUserCacheUtil.namespace_for_bind(engine) == CurrentUserCacheUtil.namespace_for_bind( + connection + ) + assert CurrentUserCacheUtil.namespace_for_bind(object()) is None + assert CurrentUserCacheUtil.namespace_for_bind(None) is None + finally: + engine.dispose() + + +@pytest.mark.asyncio +async def test_async_and_sync_bind_share_namespace() -> None: + engine = create_async_engine('sqlite+aiosqlite://') + try: + async with engine.connect() as connection: + namespace = CurrentUserCacheUtil.namespace_for_bind(engine) + assert namespace == CurrentUserCacheUtil.namespace_for_bind(engine.sync_engine) + assert namespace == CurrentUserCacheUtil.namespace_for_bind(connection) + finally: + await engine.dispose() diff --git a/ruoyi-fastapi-backend/utils/cache_util.py b/ruoyi-fastapi-backend/utils/cache_util.py new file mode 100644 index 000000000..c6c5de422 --- /dev/null +++ b/ruoyi-fastapi-backend/utils/cache_util.py @@ -0,0 +1,291 @@ +import asyncio +import hashlib +import uuid +from dataclasses import dataclass +from typing import Any + +import anyio +from redis.asyncio import Redis +from redis.exceptions import RedisError +from sqlalchemy.engine import Connection, Engine +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine + +from module_admin.entity.vo.user_vo import CurrentUserModel +from utils.log_util import logger + + +@dataclass(frozen=True, slots=True) +class CurrentUserCacheTicket: + """ + 当前用户查询开始时的缓存版本和固定有效期 + """ + + global_epoch: str + user_epoch: str + created_at_ms: int + deadline_ms: int + + +class CurrentUserCacheUtil: + """ + 当前用户快照缓存工具类 + + 数据库提交后主动失效,硬过期限制失效遗漏时的缓存复用时间。 + """ + + _OPERATION_TIMEOUT_SECONDS = 1 + _READ_RESULT_SIZE = 5 + # 用户版本在停止访问后自动回收,快照仍按查询开始时的固定期限过期 + _USER_EPOCH_TTL_SECONDS = 3600 + + _READ_SCRIPT = """ +local clock = redis.call('TIME') +local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000) +local global_epoch = redis.call('GET', KEYS[1]) +local user_epoch = redis.call('GET', KEYS[2]) +if not global_epoch then + global_epoch = ARGV[1] + redis.call('SET', KEYS[1], global_epoch) +end +if not user_epoch then + user_epoch = ARGV[2] + redis.call('SET', KEYS[2], user_epoch) +end +redis.call('EXPIRE', KEYS[2], ARGV[4]) +local value = redis.call('HMGET', KEYS[3], 'global_epoch', 'user_epoch', 'created_at', 'deadline', 'payload') +local created_at = tonumber(value[3]) +local deadline = tonumber(value[4]) +if value[1] == global_epoch and value[2] == user_epoch + and created_at and deadline and created_at <= now and deadline > now + and created_at + tonumber(ARGV[3]) > now and value[5] then + return {global_epoch, user_epoch, tostring(now), tostring(now + tonumber(ARGV[3])), value[5]} +end +return {global_epoch, user_epoch, tostring(now), tostring(now + tonumber(ARGV[3])), ''} +""" + _WRITE_SCRIPT = """ +local clock = redis.call('TIME') +local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000) +if tonumber(ARGV[3]) > now or tonumber(ARGV[4]) <= now + or redis.call('GET', KEYS[1]) ~= ARGV[1] + or redis.call('GET', KEYS[2]) ~= ARGV[2] then + return 0 +end +local previous = redis.call('HMGET', KEYS[3], 'global_epoch', 'user_epoch', 'created_at') +if previous[1] == ARGV[1] and previous[2] == ARGV[2] + and tonumber(previous[3]) and tonumber(previous[3]) > tonumber(ARGV[3]) then + return 0 +end +redis.call('HSET', KEYS[3], 'global_epoch', ARGV[1], 'user_epoch', ARGV[2], + 'created_at', ARGV[3], 'deadline', ARGV[4], 'payload', ARGV[5]) +redis.call('PEXPIREAT', KEYS[3], ARGV[4]) +return 1 +""" + _INVALIDATE_SCRIPT = """ +for index, key in ipairs(KEYS) do + redis.call('SET', key, ARGV[index]) + if tonumber(ARGV[#KEYS + 1]) > 0 then + redis.call('EXPIRE', key, ARGV[#KEYS + 1]) + end +end +return #KEYS +""" + + @staticmethod + def namespace_for_bind(bind: object) -> str | None: + """ + 根据数据库连接目标生成缓存命名空间,不访问数据库 + + :param bind: 数据库引擎或连接 + :return: 隐藏密码后的连接目标摘要,未知绑定返回None + """ + if isinstance(bind, (Connection, AsyncConnection)): + bind = bind.engine + if not isinstance(bind, (Engine, AsyncEngine)): + return None + return hashlib.sha256(bind.url.render_as_string(hide_password=True).encode()).hexdigest() + + @staticmethod + def _keys(namespace: str, user_id: int) -> tuple[str, str, str]: + """ + 获取同一数据源下的版本和用户快照键 + + :param namespace: 数据源缓存命名空间 + :param user_id: 用户id + :return: 全局版本、用户版本和快照键 + """ + digest = hashlib.sha256(namespace.encode()).hexdigest() + prefix = f'current_user_cache:v1:{{{digest}}}' + return f'{prefix}:global', f'{prefix}:user:{user_id}', f'{prefix}:snapshot:{user_id}' + + @classmethod + async def read( + cls, redis: Redis, namespace: str, user_id: int, ttl_seconds: int + ) -> tuple[CurrentUserModel | None, CurrentUserCacheTicket | None]: + """ + 原子读取用户快照,未命中时返回用于安全回填的查询凭据 + + :param redis: Redis连接对象 + :param namespace: 数据源缓存命名空间 + :param user_id: 用户id + :param ttl_seconds: 快照最大有效期,单位秒 + :return: 用户快照和查询凭据,缓存故障时均为None + """ + if ttl_seconds <= 0: + return None, None + try: + result = await cls._eval( + redis, + cls._READ_SCRIPT, + 3, + *cls._keys(namespace, user_id), + uuid.uuid4().hex, + uuid.uuid4().hex, + ttl_seconds * 1000, + cls._USER_EPOCH_TTL_SECONDS, + ) + if not isinstance(result, (list, tuple)) or len(result) != cls._READ_RESULT_SIZE: + raise ValueError('用户缓存响应格式无效') + ticket = CurrentUserCacheTicket( + global_epoch=cls._text(result[0]), + user_epoch=cls._text(result[1]), + created_at_ms=int(result[2]), + deadline_ms=int(result[3]), + ) + except (RedisError, TimeoutError, ValueError, TypeError, IndexError): + logger.warning('当前用户缓存读取失败,回源数据库') + return None, None + if not result[4]: + return None, ticket + try: + current_user = CurrentUserModel.model_validate_json(result[4]) + if ( + current_user.user is None + or current_user.user.user_id != user_id + or current_user.user.password is not None + or current_user.user.status not in (None, '0') + or current_user.user.del_flag not in (None, '0') + ): + raise ValueError('用户快照无效') + except Exception: + logger.warning('当前用户缓存内容无效,回源数据库') + return None, ticket + return current_user, None + + @classmethod + async def write( + cls, + redis: Redis, + namespace: str, + user_id: int, + current_user: CurrentUserModel, + ticket: CurrentUserCacheTicket, + ) -> bool: + """ + 版本未变化时回填用户快照,不延长查询开始时的有效期 + + :param redis: Redis连接对象 + :param namespace: 数据源缓存命名空间 + :param user_id: 用户id + :param current_user: 数据库查询得到的当前用户 + :param ticket: 查询开始前取得的版本和有效期 + :return: 是否写入成功 + """ + if current_user.user is None or current_user.user.user_id != user_id: + return False + try: + payload = current_user.model_dump_json( + by_alias=True, + include={'permissions', 'roles', 'user'}, + exclude={'user': {'password'}}, + ) + return ( + await cls._eval( + redis, + cls._WRITE_SCRIPT, + 3, + *cls._keys(namespace, user_id), + ticket.global_epoch, + ticket.user_epoch, + ticket.created_at_ms, + ticket.deadline_ms, + payload, + ) + == 1 + ) + except (RedisError, TimeoutError, ValueError, TypeError): + logger.warning('当前用户缓存写入失败,本次使用数据库查询结果') + return False + + @classmethod + async def invalidate(cls, redis: Redis, namespace: str, user_ids: set[int] | None = None) -> bool: + """ + 轮换用户或共享信息版本,使已有快照和并发查询凭据失效 + + :param redis: Redis连接对象 + :param namespace: 数据源缓存命名空间 + :param user_ids: 受影响的用户id,为None时失效全部用户快照 + :return: 是否失效成功 + """ + if user_ids is not None and not user_ids: + return True + keys = ( + [cls._keys(namespace, 0)[0]] + if user_ids is None + else [cls._keys(namespace, user_id)[1] for user_id in sorted(user_ids)] + ) + try: + return await cls._eval( + redis, + cls._INVALIDATE_SCRIPT, + len(keys), + *keys, + *(uuid.uuid4().hex for _ in keys), + cls._USER_EPOCH_TTL_SECONDS if user_ids is not None else 0, + ) == len(keys) + except (RedisError, TimeoutError): + logger.warning('当前用户缓存失效失败,等待快照硬过期') + return False + + @classmethod + async def _eval(cls, redis: Redis, script: str, key_count: int, *args: object) -> Any: + """ + 限时执行Redis脚本,取消后等待连接归还 + + :param redis: Redis连接对象 + :param script: Lua脚本 + :param key_count: 脚本键数量 + :param args: 脚本键和参数 + :return: Redis响应值 + """ + task = asyncio.ensure_future(redis.eval(script, key_count, *args)) + try: + with anyio.fail_after(cls._OPERATION_TIMEOUT_SECONDS): + return await asyncio.shield(task) + finally: + if not task.done(): + # 隔离AnyIO的重复取消,避免中断redis-py归还共享连接。 + task.cancel() + settled = asyncio.gather(task, return_exceptions=True) + cancelled = False + with anyio.CancelScope(shield=True): + while not settled.done(): + try: + await asyncio.shield(settled) + except asyncio.CancelledError: # noqa: PERF203 + cancelled = True + if cancelled: + raise asyncio.CancelledError + elif not task.cancelled(): + task.exception() + # 连接清理可能超过命令超时,清理期间的取消仍需向调用方传播。 + await anyio.lowlevel.checkpoint_if_cancelled() + + @staticmethod + def _text(value: str | bytes) -> str: + """ + 兼容Redis字符串和字节响应 + + :param value: Redis响应值 + :return: 字符串 + """ + return value.decode() if isinstance(value, bytes) else value From 0c483bfe6926183efb52cc4f0462b6ca268c45cb Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Thu, 29 Oct 2026 11:11:27 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E7=BC=93=E5=AD=98=E4=B8=8E=E4=BA=8B=E5=8A=A1=E5=A4=B1?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-fastapi-backend/common/auth_policy.py | 2 +- ruoyi-fastapi-backend/config/database.py | 324 +++++- .../controller/login_controller.py | 1 - .../module_admin/service/login_service.py | 59 +- .../plugins/core/runtime/browser_session.py | 4 +- ruoyi-fastapi-backend/server.py | 7 +- .../test_current_user_cache_resources.py | 218 +++++ .../test_current_user_cache_transactions.py | 921 ++++++++++++++++++ .../service/test_cached_session_races.py | 229 +++++ .../test_current_user_cache_integration.py | 501 ++++++++++ .../test_current_user_cache_mutations.py | 220 +++++ .../service/test_current_user_resolution.py | 1 + .../tests/server/test_plugin_runtime.py | 6 +- 13 files changed, 2475 insertions(+), 18 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/config/test_current_user_cache_resources.py create mode 100644 ruoyi-fastapi-backend/tests/config/test_current_user_cache_transactions.py create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_cached_session_races.py create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_cache_integration.py create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_cache_mutations.py diff --git a/ruoyi-fastapi-backend/common/auth_policy.py b/ruoyi-fastapi-backend/common/auth_policy.py index 828644769..9d2baa930 100644 --- a/ruoyi-fastapi-backend/common/auth_policy.py +++ b/ruoyi-fastapi-backend/common/auth_policy.py @@ -164,7 +164,7 @@ def get_auth_mode( :return: 前置认证、显式认证或排除认证 """ route = request.scope.get('route') - # include_router延迟注册时,父路由和插件追加的依赖在实际路由上下文中。 + # FastAPI在include_router延迟注册时写入实际上下文,包含父路由和插件追加的依赖。 context = request.scope.get('fastapi', {}).get('effective_route_context') if context is not None and context.original_route is route: route = context diff --git a/ruoyi-fastapi-backend/config/database.py b/ruoyi-fastapi-backend/config/database.py index 9778262d4..2023a74db 100644 --- a/ruoyi-fastapi-backend/config/database.py +++ b/ruoyi-fastapi-backend/config/database.py @@ -1,14 +1,16 @@ import asyncio import logging -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from contextlib import asynccontextmanager from dataclasses import dataclass, field from datetime import datetime, timedelta from functools import cache from typing import Any, TypeVar, cast +import anyio +import anyio.lowlevel from pydantic import SecretStr -from sqlalchemy import URL, Engine, create_engine, text +from sqlalchemy import URL, Engine, create_engine, event, inspect, text from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import ( AsyncAttrs, @@ -18,9 +20,14 @@ async_sessionmaker, create_async_engine, ) -from sqlalchemy.orm import DeclarativeBase, sessionmaker - -from config.env import DataBaseConfig, DataBaseSettings, DataSourceSettings +from sqlalchemy.orm import DeclarativeBase, FromStatement, ORMExecuteState, Session, SessionTransaction, sessionmaker +from sqlalchemy.sql import operators, visitors +from sqlalchemy.sql.dml import UpdateBase +from sqlalchemy.sql.elements import BinaryExpression, BindParameter, BooleanClauseList, TextClause +from sqlalchemy.sql.selectable import Alias, Join, TextualSelect +from sqlalchemy.util import await_only + +from config.env import AppConfig, DataBaseConfig, DataBaseSettings, DataSourceSettings from exceptions.exception import ( DataSourceException, DataSourceInitializationException, @@ -295,6 +302,292 @@ def create_sync_db_engine( ) +class CurrentUserCacheSession(Session): + """ + 登记当前用户资料和权限变更,在事务提交后使缓存失效 + """ + + _USER_TABLES = frozenset({'sys_user', 'sys_user_role', 'sys_user_post'}) + _SHARED_TABLES = frozenset({'sys_role', 'sys_menu', 'sys_dept', 'sys_post', 'sys_role_menu', 'sys_role_dept'}) + _CHANGE_KEY = 'current_user_cache_changes' + _REDIS_KEY = 'current_user_cache_redis' + _INVALIDATE_TIMEOUT_SECONDS = 0.3 + _CLOSE_TIMEOUT_SECONDS = 0.2 + + @staticmethod + def _table_names(table: Any) -> set[str]: + """ + 解析别名和多表更新目标中的原始表名 + + :param table: SQLAlchemy写入目标 + :return: 原始表名集合 + """ + if isinstance(table, Alias): + return CurrentUserCacheSession._table_names(table.element) + if isinstance(table, Join): + return CurrentUserCacheSession._table_names(table.left) | CurrentUserCacheSession._table_names(table.right) + name = getattr(table, 'name', None) + return {name} if name is not None else set() + + @staticmethod + def _has_unscoped_write(statement: Any) -> bool: + """ + 识别原生SQL和包装语句中无法精确定位用户的写入 + + :param statement: SQLAlchemy执行语句 + :return: 是否需要全局失效 + """ + for element in visitors.iterate(statement): + raw_sql = element if element is statement else None + if isinstance(element, (FromStatement, TextualSelect)): + raw_sql = element.element + if isinstance(raw_sql, TextClause): + # 只识别完整SQL,WHERE等位置的文本片段不表示写入。 + sql = raw_sql.text.lstrip().upper() + if not sql.startswith(('SELECT ', 'SELECT\n', 'SHOW ', 'PRAGMA ')): + return True + if isinstance(element, UpdateBase) and element is not statement: + names = CurrentUserCacheSession._table_names(element.table) + if names & (CurrentUserCacheSession._USER_TABLES | CurrentUserCacheSession._SHARED_TABLES): + return True + return False + + def _record_cache_change(self, user_ids: set[int] | None) -> None: + """ + 按当前事务登记变更,None表示共享权限或无法确定用户范围 + + :param user_ids: 需要失效的用户ID集合 + :return: None + """ + transaction = self.get_nested_transaction() or self.get_transaction() + if transaction is None: + return + changes = self.info.setdefault(self._CHANGE_KEY, {}) + if transaction not in changes: + changes[transaction] = user_ids + elif changes[transaction] is not None: + changes[transaction] = None if user_ids is None else changes[transaction] | user_ids + + @classmethod + def _where_user_ids(cls, clause: Any, table_name: str, parameters: Any) -> set[int] | None: + """ + 仅识别明确的用户ID等值条件,复杂条件保守失效全部快照 + + :param clause: 更新或删除语句的条件 + :param table_name: 目标表名 + :param parameters: 执行参数 + :return: 用户ID集合,无法确定时返回None + """ + if isinstance(clause, BooleanClauseList) and clause.operator is operators.and_: + for item in clause.clauses: + ids = cls._where_user_ids(item, table_name, parameters) + if ids is not None: + return ids + return None + if not isinstance(clause, BinaryExpression) or clause.operator not in (operators.eq, operators.in_op): + return None + column, value = clause.left, clause.right + if isinstance(column, BindParameter) and clause.operator is operators.eq: + column, value = value, column + if ( + getattr(column, 'name', None) != 'user_id' + or getattr(getattr(column, 'table', None), 'name', None) != table_name + or not isinstance(value, BindParameter) + or value.callable is not None + ): + return None + parameter_sets = parameters if isinstance(parameters, (list, tuple)) else [parameters or {}] + user_ids = set() + for item in parameter_sets: + if not isinstance(item, Mapping): + return None + raw = item.get(value.key, value.value) + values = raw if clause.operator is operators.in_op and isinstance(raw, (list, tuple, set)) else [raw] + if not all(isinstance(user_id, int) and not isinstance(user_id, bool) for user_id in values): + return None + user_ids.update(values) + return user_ids + + async def _invalidate_cache(self, user_ids: set[int] | None) -> None: + """ + 提交完成后轮换缓存版本,Redis异常由短时硬过期兜底 + + :param user_ids: 需要失效的用户ID集合,None表示全部用户 + :return: None + """ + from config.get_redis import RedisUtil # noqa: PLC0415 + from utils.cache_util import CurrentUserCacheUtil # noqa: PLC0415 + + redis = self.info.get(self._REDIS_KEY) + owns_redis = redis is None + try: + # 限制提交后的缓存失效等待时间,超时后仍需完成连接清理。 + with anyio.fail_after(self._INVALIDATE_TIMEOUT_SECONDS): + namespace = CurrentUserCacheUtil.namespace_for_bind(self.get_bind()) + if namespace is None: + return + if owns_redis: + redis = await RedisUtil.create_redis_pool(log_enabled=False, log_start_enabled=False) + await CurrentUserCacheUtil.invalidate(redis, namespace, user_ids) + except Exception as exc: + logger.warning(f'当前用户缓存失效失败,将等待缓存过期:{type(exc).__name__}') + finally: + if owns_redis and redis is not None: + try: + # 外层取消不能反复中断资源清理,关闭仍受自身超时限制。 + with anyio.fail_after(self._CLOSE_TIMEOUT_SECONDS, shield=True): + await redis.aclose() + except Exception as exc: + logger.warning(f'关闭当前用户缓存连接失败:{type(exc).__name__}') + # 关闭期间被屏蔽的外层取消仍需交还调用方。 + await anyio.lowlevel.checkpoint_if_cancelled() + + +@event.listens_for(CurrentUserCacheSession, 'do_orm_execute', retval=True) +def _track_current_user_statement(state: ORMExecuteState) -> Any: + """ + 登记成功执行的用户及权限写入,覆盖ORM批量操作和Core语句 + + :param state: 当前SQL执行状态 + :return: 原语句执行结果 + """ + result = state.invoke_statement() + if not AppConfig.app_current_user_cache_enabled: + return result + try: + statement = state.statement + if CurrentUserCacheSession._has_unscoped_write(statement): + state.session._record_cache_change(None) + elif state.is_insert or state.is_update or state.is_delete: + target = getattr(statement, 'table', None) + table_names = CurrentUserCacheSession._table_names(target) + table_name = next(iter(table_names)) if len(table_names) == 1 else None + if table_names & CurrentUserCacheSession._SHARED_TABLES or ( + isinstance(target, (Alias, Join)) and table_names & CurrentUserCacheSession._USER_TABLES + ): + # 别名及多表写入的WHERE可能引用其他表,不能据此缩小目标用户范围。 + state.session._record_cache_change(None) + elif table_name in CurrentUserCacheSession._USER_TABLES: + ids = CurrentUserCacheSession._where_user_ids( + getattr(statement, 'whereclause', None), table_name, state.parameters + ) + bulk_entity = None + if ( + state.is_update + and state.is_orm_statement + and isinstance(state.parameters, list) + and state.execution_options.get('dml_strategy') in (None, 'auto', 'bulk') + ): + try: + bulk_entity = statement.entity_description.get('entity') + except (AttributeError, KeyError): + pass + assigned_columns = list(getattr(statement, '_values', None) or {}) + assigned_columns.extend(column for column, _value in getattr(statement, '_ordered_values', None) or ()) + if state.is_update and any(getattr(column, 'key', column) == 'user_id' for column in assigned_columns): + # 用户归属变更需覆盖新旧用户,自定义绑定及表达式不能只按参数名判断。 + ids = None + elif bulk_entity is not None: + # ORM批量更新会自动附加主键条件,用户资料和登录时间更新走此路径。 + values = [item.get('user_id') for item in state.parameters] + ids = ( + set(values) + if all(isinstance(value, int) and not isinstance(value, bool) for value in values) + else None + ) + elif state.is_update and ( + 'user_id' in statement.compile().params + or any( + isinstance(parameters, Mapping) and 'user_id' in parameters + for parameters in ( + state.parameters if isinstance(state.parameters, (list, tuple)) else [state.parameters] + ) + ) + ): + # 修改主键或未知用户范围时不能只失效原用户。 + ids = None + state.session._record_cache_change(ids) + except Exception as exc: + # SQL已执行成功,范围分析失败不能改变业务结果。 + logger.warning(f'当前用户缓存变更范围分析失败,将失效全部快照:{type(exc).__name__}') + state.session._record_cache_change(None) + return result + + +@event.listens_for(CurrentUserCacheSession, 'before_flush') +def _track_current_user_flush(session: CurrentUserCacheSession, _context: Any, _instances: Any) -> None: + """ + 登记ORM对象变更,覆盖直接修改用户和权限模型的入口 + + :param session: 当前数据库会话 + :param _context: 刷新上下文 + :param _instances: 刷新对象 + :return: None + """ + if not AppConfig.app_current_user_cache_enabled: + return + for instance in session.new | session.dirty | session.deleted: + state = inspect(instance) + table_name = state.mapper.local_table.name + if instance in session.dirty and not session.is_modified(instance, include_collections=False): + continue + if table_name in CurrentUserCacheSession._USER_TABLES: + user_id = getattr(instance, 'user_id', None) + if user_id is None: + session._record_cache_change(None) + else: + previous_ids = state.attrs.user_id.history.deleted + session._record_cache_change({user_id, *previous_ids}) + elif table_name in CurrentUserCacheSession._SHARED_TABLES: + session._record_cache_change(None) + + +@event.listens_for(CurrentUserCacheSession, 'after_commit') +def _invalidate_current_user_cache(session: CurrentUserCacheSession) -> None: + """ + 保存点提交只合并记录,最外层提交后才执行缓存失效 + + :param session: 当前数据库会话 + :return: None + """ + try: + transaction = session.get_nested_transaction() or session.get_transaction() + changes = session.info.get(session._CHANGE_KEY, {}) + if transaction not in changes: + return + user_ids = changes.pop(transaction) + if transaction.nested: + parent = transaction.parent + if parent not in changes: + changes[parent] = user_ids + elif changes[parent] is not None: + changes[parent] = None if user_ids is None else changes[parent] | user_ids + return + session.info.pop(session._CHANGE_KEY, None) + if user_ids != set(): + await_only(session._invalidate_cache(user_ids)) + except Exception as exc: + # 数据库已提交,缓存监听器异常不能改变提交结果,取消仍向上传播。 + session.info.pop(session._CHANGE_KEY, None) + logger.warning(f'当前用户缓存提交监听失败,将等待缓存过期:{type(exc).__name__}') + + +@event.listens_for(CurrentUserCacheSession, 'after_transaction_end') +def _discard_current_user_changes(session: CurrentUserCacheSession, transaction: SessionTransaction) -> None: + """ + 清理已结束事务的记录,回滚和关闭会话不影响Redis缓存 + + :param session: 当前数据库会话 + :param transaction: 已结束的事务 + :return: None + """ + changes = session.info.get(session._CHANGE_KEY) + if changes is not None: + changes.pop(transaction, None) + if not changes: + session.info.pop(session._CHANGE_KEY, None) + + def create_async_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: """ 创建异步Session工厂 @@ -302,7 +595,13 @@ def create_async_session_factory(engine: AsyncEngine) -> async_sessionmaker[Asyn :param engine: 异步SQLAlchemy Engine :return: 异步Session工厂 """ - return async_sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) + return async_sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, + sync_session_class=CurrentUserCacheSession, + ) def create_sync_session_factory(engine: Engine) -> sessionmaker: @@ -344,6 +643,16 @@ def __init__(self, settings: DataBaseSettings | None = None) -> None: self._initialized = False self._log_enabled = True self._configs = dict(self.settings.db_sources) + self._cache_redis: Any | None = None + + def set_cache_redis(self, redis: Any | None) -> None: + """ + 绑定应用生命周期管理的Redis,数据库会话只借用连接池 + + :param redis: 应用Redis客户端,None表示解除绑定 + :return: None + """ + self._cache_redis = redis def _resolve_name(self, name: str | None = None) -> str: """ @@ -591,6 +900,8 @@ async def session(self, name: str | None = None, *, log_sql: bool = True) -> Asy session_options['bind'] = quiet_sql_engine(runtime.async_engine) try: async with factory(**session_options) as current_db: + if self._cache_redis is not None: + current_db.info[CurrentUserCacheSession._REDIS_KEY] = self._cache_redis yield current_db except DBAPIError as exc: if not exc.connection_invalidated: @@ -605,6 +916,7 @@ async def dispose_all(self) -> None: :return: None """ runtimes = tuple(self._runtimes.values()) + self._cache_redis = None self._runtimes.clear() self._initialized = False for runtime in runtimes: diff --git a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py index 9b4a5171c..b60c4dcc2 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py @@ -103,7 +103,6 @@ async def login( description='用于获取当前登录用户的信息', response_model=DynamicResponseModel[CurrentUserModel], ) -@ApiCache(namespace=ApiNamespace.LOGIN_USER_INFO) async def get_login_user_info( request: Request, current_user: Annotated[CurrentUserModel, CurrentUserDependency()] ) -> Response: diff --git a/ruoyi-fastapi-backend/module_admin/service/login_service.py b/ruoyi-fastapi-backend/module_admin/service/login_service.py index e25dbfad2..6bbe667dc 100644 --- a/ruoyi-fastapi-backend/module_admin/service/login_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/login_service.py @@ -5,7 +5,9 @@ from fastapi import Depends, Form, HTTPException, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from sqlalchemy import Row +from redis.asyncio import Redis +from sqlalchemy import Engine, Row +from sqlalchemy.exc import UnboundExecutionError from sqlalchemy.ext.asyncio import AsyncSession from common.aspect.db_session import DBSessionDependency @@ -19,7 +21,9 @@ from module_admin.dao.user_dao import UserDao from module_admin.entity.do.dept_do import SysDept from module_admin.entity.do.menu_do import SysMenu -from module_admin.entity.do.user_do import SysUser +from module_admin.entity.do.post_do import SysPost +from module_admin.entity.do.role_do import SysRole, SysRoleMenu +from module_admin.entity.do.user_do import SysUser, SysUserPost, SysUserRole from module_admin.entity.vo.login_vo import MenuTreeModel, MetaModel, RouterModel, SmsCode, UserLogin, UserRegister from module_admin.entity.vo.user_vo import AddUserModel, CurrentUserModel, ResetUserModel, TokenData, UserInfoModel from module_admin.service.user_service import UserService @@ -28,6 +32,7 @@ CredentialAuthenticationService, ) from plugins.core.management.service.router_metadata import PluginRouterMetadataService +from utils.cache_util import CurrentUserCacheUtil from utils.client_ip_util import ClientIPUtil from utils.common_util import CamelCaseUtil from utils.jwt_util import JwtUtil @@ -208,7 +213,7 @@ async def get_current_user_for_plugin_session( :param request: 当前插件请求 :param token: 插件会话绑定的宿主登录凭证 :param query_db: 用于查询用户及权限的数据库会话 - :return: 通过宿主身份及实时权限校验的当前用户 + :return: 通过宿主身份及权限校验的当前用户 """ return await cls._resolve_current_user(request, token, query_db, refresh_session=False) @@ -217,13 +222,13 @@ async def _resolve_current_user( cls, request: Request, token: str, query_db: AsyncSession, *, refresh_session: bool ) -> CurrentUserModel: """ - 共享身份和实时权限校验,调用方明确是否续期主会话。 + 共享身份和权限校验,调用方明确是否续期主会话。 :param request: 当前请求 :param token: 宿主登录凭证 :param query_db: 用于查询用户及权限的数据库会话 :param refresh_session: 是否续期宿主主登录会话 - :return: 通过宿主身份及实时权限校验的当前用户 + :return: 通过宿主身份及权限校验的当前用户 """ try: if token.startswith('Bearer '): @@ -245,7 +250,7 @@ async def _resolve_current_user( logger.warning('用户token已失效,请重新登录') raise AuthException(data='', message='用户token已失效,请重新登录') - current_user = await cls._get_current_user_from_db(query_db, token_data.user_id) + current_user = await cls._get_current_user_snapshot(redis, query_db, token_data.user_id) # 密码策略及过期状态在本次鉴权时计算 current_user.app_timezone = AppConfig.app_timezone current_user.pwd_chrtype = await cls.get_sys_account_chrtype(request) @@ -274,6 +279,48 @@ async def _resolve_current_user( RequestContext.set_current_user(current_user) return current_user + @classmethod + async def _get_current_user_snapshot(cls, redis: Redis, query_db: AsyncSession, user_id: int) -> CurrentUserModel: + """ + 获取当前用户快照,已有事务继续使用调用方的查询语义 + + :param redis: Redis连接对象 + :param query_db: orm对象 + :param user_id: 用户id + :return: 本次鉴权使用的当前用户信息 + """ + # 已有事务或ORM对象可能包含未提交修改、旧快照,不参与跨请求缓存 + if ( + not AppConfig.app_current_user_cache_enabled + or not isinstance(query_db, AsyncSession) + or query_db.in_transaction() + or query_db.identity_map + ): + return await cls._get_current_user_from_db(query_db, user_id) + try: + bind = query_db.get_bind() + except UnboundExecutionError: + return await cls._get_current_user_from_db(query_db, user_id) + # 外部Connection可能已有未提交事务,多绑定会话也不能共用单一缓存命名空间 + if not isinstance(bind, Engine) or any( + query_db.get_bind(mapper=model) is not bind + for model in (SysUser, SysDept, SysRole, SysPost, SysMenu, SysUserRole, SysUserPost, SysRoleMenu) + ): + return await cls._get_current_user_from_db(query_db, user_id) + namespace = CurrentUserCacheUtil.namespace_for_bind(bind) + if namespace is None: + return await cls._get_current_user_from_db(query_db, user_id) + current_user, ticket = await CurrentUserCacheUtil.read( + redis, namespace, user_id, AppConfig.app_current_user_cache_ttl_seconds + ) + if current_user is not None: + return current_user + # 查询从新的事务快照开始;不提交或回滚调用方的数据库会话 + current_user = await cls._get_current_user_from_db(query_db, user_id) + if ticket is not None: + await CurrentUserCacheUtil.write(redis, namespace, user_id, current_user, ticket) + return current_user + @classmethod async def _get_current_user_from_db(cls, query_db: AsyncSession, user_id: int) -> CurrentUserModel: """ diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/browser_session.py b/ruoyi-fastapi-backend/plugins/core/runtime/browser_session.py index e07261755..cf5195176 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/browser_session.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/browser_session.py @@ -166,14 +166,14 @@ async def authenticate_browser_session( request: Request, *, plugin_id: str, version: str, query_db: Any, websocket: bool = False ) -> Any: """ - 每次使用时回查宿主 token,再复用原有登录服务获取实时用户和权限。 + 每次使用时回查宿主 token,再复用原有登录服务获取用户和权限。 :param request: 携带插件 Cookie 的请求 :param plugin_id: 插件 ID :param version: 当前插件版本 :param query_db: 用于查询用户及权限的数据库会话 :param websocket: 是否按 WebSocket 请求执行来源校验 - :return: 通过宿主身份及实时权限校验的当前用户 + :return: 通过宿主身份及权限校验的当前用户 """ cookie = request.cookies.get(f'{COOKIE_NAME_PREFIX}{plugin_id}', '') matched = COOKIE_PATTERN.fullmatch(cookie) diff --git a/ruoyi-fastapi-backend/server.py b/ruoyi-fastapi-backend/server.py index 526eebeb8..36b9b6318 100644 --- a/ruoyi-fastapi-backend/server.py +++ b/ruoyi-fastapi-backend/server.py @@ -59,6 +59,7 @@ async def _stop_background_tasks(app: FastAPI) -> None: # Scheduler负责停止续期并释放Application租约,必须先于Redis连接池关闭。 await SchedulerManager.close_system_scheduler() finally: + DataSourceRegistry.set_cache_redis(None) await RedisUtil.close_redis_pool(app) finally: await DataSourceRegistry.dispose_all() @@ -188,6 +189,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.plugin_application_runtime_started = False try: app.state.redis = await RedisUtil.create_redis_pool(log_enabled=False) + DataSourceRegistry.set_cache_redis(app.state.redis) application_lock_owner_token = SchedulerManager.get_application_lock_owner_token() application_leader = await StartupUtil.acquire_application_leader( redis=app.state.redis, @@ -228,7 +230,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await logger.complete() yield finally: - await _shutdown_application_runtime(app) + try: + await _shutdown_application_runtime(app) + finally: + DataSourceRegistry.set_cache_redis(None) def create_app() -> FastAPI: diff --git a/ruoyi-fastapi-backend/tests/config/test_current_user_cache_resources.py b/ruoyi-fastapi-backend/tests/config/test_current_user_cache_resources.py new file mode 100644 index 000000000..13cc26aa1 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/config/test_current_user_cache_resources.py @@ -0,0 +1,218 @@ +import asyncio +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, call + +import anyio +import pytest +from redis.exceptions import ConnectionError as RedisConnectionError +from sqlalchemy import update + +from config.database import CurrentUserCacheSession, _DataSourceRegistry +from config.get_redis import RedisUtil +from tests.config.test_current_user_cache_transactions import ( + _ORIGINAL_INVALIDATE, + CacheUser, + cache_transactions, # noqa: F401 +) +from utils.cache_util import CurrentUserCacheUtil + + +@pytest.fixture +def shared_cache_client(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + """提交后失效借用应用连接,只监测新建和关闭行为。""" + redis = SimpleNamespace(aclose=AsyncMock()) + create = AsyncMock(side_effect=AssertionError('借用应用Redis时不能另建连接池')) + invalidate = AsyncMock(return_value=True) + monkeypatch.setattr(RedisUtil, 'create_redis_pool', create) + monkeypatch.setattr(CurrentUserCacheUtil, 'invalidate', invalidate) + return SimpleNamespace(redis=redis, create=create, invalidate=invalidate) + + +@pytest.mark.asyncio +async def test_shared_redis_is_reused_across_commits( + cache_transactions: SimpleNamespace, # noqa: F811 + shared_cache_client: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', _ORIGINAL_INVALIDATE) + shared = shared_cache_client + async with cache_transactions.sessions(info={CurrentUserCacheSession._REDIS_KEY: shared.redis}) as db: + for user_id, name in ((1, 'first-commit'), (2, 'second-commit')): + await db.execute(update(CacheUser).where(CacheUser.user_id == user_id).values(user_name=name)) + await db.commit() + namespace = CurrentUserCacheUtil.namespace_for_bind(cache_transactions.engine) + assert shared.invalidate.await_args_list == [call(shared.redis, namespace, {1}), call(shared.redis, namespace, {2})] + shared.create.assert_not_awaited() + shared.redis.aclose.assert_not_awaited() + async with cache_transactions.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'first-commit' + assert (await db.get(CacheUser, 2)).user_name == 'second-commit' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['redis', 'timeout']) +async def test_shared_cache_failure_preserves_commit_without_closing_pool( + cache_transactions: SimpleNamespace, # noqa: F811 + shared_cache_client: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', _ORIGINAL_INVALIDATE) + monkeypatch.setattr(CurrentUserCacheSession, '_INVALIDATE_TIMEOUT_SECONDS', 0.01) + shared = shared_cache_client + stopped = anyio.Event() + + async def invalidate(*args: Any) -> None: + if failure == 'redis': + raise RedisConnectionError('shared cache unavailable') + try: + await anyio.sleep_forever() + finally: + stopped.set() + + shared.invalidate.side_effect = invalidate + async with cache_transactions.sessions(info={CurrentUserCacheSession._REDIS_KEY: shared.redis}) as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + await db.commit() + if failure == 'timeout': + assert stopped.is_set() + shared.create.assert_not_awaited() + shared.redis.aclose.assert_not_awaited() + async with cache_transactions.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('cancellation', ['asyncio', 'task_group']) +async def test_cancelled_shared_invalidation_preserves_borrowed_pool( + cache_transactions: SimpleNamespace, # noqa: F811 + shared_cache_client: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + cancellation: str, +) -> None: + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', _ORIGINAL_INVALIDATE) + shared = shared_cache_client + entered = anyio.Event() + stopped = anyio.Event() + cancelled = anyio.Event() + + async def invalidate(*args: Any) -> None: + entered.set() + try: + await anyio.sleep_forever() + finally: + stopped.set() + + shared.invalidate.side_effect = invalidate + async with cache_transactions.sessions(info={CurrentUserCacheSession._REDIS_KEY: shared.redis}) as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + + async def commit() -> None: + try: + await db.commit() + except asyncio.CancelledError: + cancelled.set() + raise + + if cancellation == 'asyncio': + pending = asyncio.create_task(commit()) + try: + await entered.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + finally: + if not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + else: + async with anyio.create_task_group() as group: + group.start_soon(commit) + await entered.wait() + group.cancel_scope.cancel() + assert cancelled.is_set() + assert stopped.is_set() + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + shared.create.assert_not_awaited() + shared.redis.aclose.assert_not_awaited() + async with cache_transactions.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + + +def _registry(env: SimpleNamespace) -> _DataSourceRegistry: + """使用隔离SQLite引擎验证会话资源注入,不访问配置中的业务数据源。""" + registry = _DataSourceRegistry( + SimpleNamespace(db_default_source='primary', db_sources={'primary': object(), 'reporting': object()}) + ) + for name in ('primary', 'reporting'): + runtime = registry._runtime(name) + runtime.async_engine = env.engine + runtime.async_session_factory = env.sessions + runtime.available = True + return registry + + +@pytest.mark.asyncio +@pytest.mark.parametrize('source', [None, 'reporting']) +@pytest.mark.parametrize('log_sql', [True, False]) +async def test_registry_sessions_borrow_registered_cache_client( + cache_transactions: SimpleNamespace, # noqa: F811 + shared_cache_client: SimpleNamespace, + source: str | None, + log_sql: bool, +) -> None: + registry = _registry(cache_transactions) + redis = shared_cache_client.redis + registry.set_cache_redis(redis) + try: + async with registry.session(source, log_sql=log_sql) as db: + assert db.info[CurrentUserCacheSession._REDIS_KEY] is redis + assert isinstance(db.sync_session, CurrentUserCacheSession) + assert (await db.get(CacheUser, 1)).user_name == 'first' + finally: + await registry.dispose_all() + assert registry._cache_redis is None + shared_cache_client.create.assert_not_awaited() + redis.aclose.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_detaching_shared_redis_only_changes_new_sessions( + cache_transactions: SimpleNamespace, # noqa: F811 + shared_cache_client: SimpleNamespace, +) -> None: + registry = _registry(cache_transactions) + redis = shared_cache_client.redis + registry.set_cache_redis(redis) + try: + async with registry.session() as first: + registry.set_cache_redis(None) + async with registry.session() as second: + assert first.info[CurrentUserCacheSession._REDIS_KEY] is redis + assert second.info.get(CurrentUserCacheSession._REDIS_KEY) is None + finally: + await registry.dispose_all() + redis.aclose.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dispose_failure', [False, True]) +async def test_registry_disposal_releases_reference_without_closing_borrowed_client( + cache_transactions: SimpleNamespace, # noqa: F811 + shared_cache_client: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + dispose_failure: bool, +) -> None: + registry = _registry(cache_transactions) + redis = shared_cache_client.redis + registry.set_cache_redis(redis) + with monkeypatch.context() as patch: + if dispose_failure: + patch.setattr( + type(cache_transactions.engine), 'dispose', AsyncMock(side_effect=RuntimeError('dispose failed')) + ) + await registry.dispose_all() + assert registry._cache_redis is None + assert registry._runtimes == {} + redis.aclose.assert_not_awaited() diff --git a/ruoyi-fastapi-backend/tests/config/test_current_user_cache_transactions.py b/ruoyi-fastapi-backend/tests/config/test_current_user_cache_transactions.py new file mode 100644 index 000000000..33ca18cd6 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/config/test_current_user_cache_transactions.py @@ -0,0 +1,921 @@ +import asyncio +from collections.abc import AsyncIterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock + +import anyio +import anyio.lowlevel +import pytest +import pytest_asyncio +from redis.exceptions import ConnectionError as RedisConnectionError +from sqlalchemy import ( + Column, + Integer, + String, + Table, + bindparam, + create_engine, + delete, + insert, + or_, + select, + text, + update, +) +from sqlalchemy.dialects import mysql, postgresql +from sqlalchemy.exc import IntegrityError, SAWarning +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column + +from config.database import CurrentUserCacheSession, _track_current_user_statement, create_async_session_factory +from config.env import AppConfig +from config.get_redis import RedisUtil +from utils.cache_util import CurrentUserCacheUtil + +SECOND_USER_ID = 2 +_ORIGINAL_INVALIDATE = CurrentUserCacheSession._invalidate_cache + + +class CacheTestBase(DeclarativeBase): + pass + + +class CacheUser(CacheTestBase): + __tablename__ = 'sys_user' + + user_id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_name: Mapped[str] = mapped_column(String, unique=True) + + +class CacheRole(CacheTestBase): + __tablename__ = 'sys_role' + + role_id: Mapped[int] = mapped_column(Integer, primary_key=True) + role_name: Mapped[str] = mapped_column(String) + + +class CacheUserRole(CacheTestBase): + __tablename__ = 'sys_user_role' + + user_id: Mapped[int] = mapped_column(Integer, primary_key=True) + role_id: Mapped[int] = mapped_column(Integer, primary_key=True) + + +class CacheJob(CacheTestBase): + __tablename__ = 'sys_job' + + job_id: Mapped[int] = mapped_column(Integer, primary_key=True) + job_name: Mapped[str] = mapped_column(String) + + +SHARED_TABLES = { + name: Table(name, CacheTestBase.metadata, Column('id', Integer, primary_key=True), Column('name', String)) + for name in ('sys_menu', 'sys_dept', 'sys_post', 'sys_role_menu', 'sys_role_dept') +} + + +@pytest_asyncio.fixture +async def cache_transactions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[SimpleNamespace]: + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', True) + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "cache-transactions.db"}') + async with engine.begin() as connection: + await connection.run_sync(CacheTestBase.metadata.create_all) + await connection.execute( + insert(CacheUser), [{'user_id': 1, 'user_name': 'first'}, {'user_id': 2, 'user_name': 'second'}] + ) + await connection.execute(insert(CacheRole), [{'role_id': 1, 'role_name': 'reader'}]) + await connection.execute(insert(CacheJob), [{'job_id': 1, 'job_name': 'job'}]) + invalidations = [] + + async def invalidate(session: CurrentUserCacheSession, user_ids: set[int] | None) -> None: + async with engine.connect() as connection: + names = (await connection.execute(select(CacheUser.user_name).order_by(CacheUser.user_id))).scalars().all() + invalidations.append((user_ids, names)) + + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', invalidate) + try: + yield SimpleNamespace(engine=engine, sessions=create_async_session_factory(engine), invalidations=invalidations) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_user_change_invalidates_only_after_commit(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions() as db: + user = await db.get(CacheUser, 1) + user.user_name = 'changed' + await db.flush() + assert env.invalidations == [] + await db.commit() + assert env.invalidations == [({1}, ['changed', 'second'])] + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + + +@pytest.mark.asyncio +@pytest.mark.parametrize('finish', ['rollback', 'close']) +async def test_rollback_and_close_discard_changes(cache_transactions: SimpleNamespace, finish: str) -> None: + env = cache_transactions + async with env.sessions() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='changed')) + await getattr(db, finish)() + assert env.invalidations == [] + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + + +@pytest.mark.asyncio +async def test_nested_commit_waits_for_outer_commit(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='outer')) + async with db.begin_nested(): + await db.execute(update(CacheUser).where(CacheUser.user_id == SECOND_USER_ID).values(user_name='inner')) + assert env.invalidations == [] + assert env.invalidations == [({1, 2}, ['outer', 'inner'])] + + +@pytest.mark.asyncio +async def test_nested_rollback_does_not_invalidate_rolled_back_user(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='outer')) + savepoint = await db.begin_nested() + await db.execute(update(CacheUser).where(CacheUser.user_id == SECOND_USER_ID).values(user_name='inner')) + await savepoint.rollback() + assert env.invalidations == [({1}, ['outer', 'second'])] + + +@pytest.mark.asyncio +async def test_nested_commit_then_outer_rollback_does_not_invalidate(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='outer')) + async with db.begin_nested(): + await db.execute(update(CacheRole).values(role_name='changed')) + await db.rollback() + assert env.invalidations == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'statement, expected', + [ + (update(CacheUser).where(CacheUser.user_id.in_([1, 2])).values(user_name=CacheUser.user_name + '-new'), {1, 2}), + ( + update(CacheUser).where(CacheUser.user_id == 1, CacheUser.user_name == 'first').values(user_name='changed'), + {1}, + ), + ( + update(CacheUser) + .where(or_(CacheUser.user_id == 1, CacheUser.user_name == 'second')) + .values(user_name=CacheUser.user_name + '-new'), + None, + ), + (update(CacheUser).where(CacheUser.user_id == 1).values(user_id=3), None), + (delete(CacheUserRole).where(CacheUserRole.user_id == 1), {1}), + (delete(CacheRole).where(CacheRole.role_id == 1), None), + (text("UPDATE sys_user SET user_name = 'changed' WHERE user_id = 1"), None), + ], +) +async def test_core_and_text_mutations_are_tracked( + cache_transactions: SimpleNamespace, statement: Any, expected: set[int] | None +) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + await db.execute(statement) + assert len(env.invalidations) == 1 + assert env.invalidations[0][0] == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize('alias_type', ['orm', 'core', 'nested']) +@pytest.mark.parametrize('operation', ['update', 'delete']) +@pytest.mark.parametrize( + 'model, id_column, name_column, tracked', + [ + (CacheUser, 'user_id', 'user_name', True), + (CacheRole, 'role_id', 'role_name', True), + (CacheJob, 'job_id', 'job_name', False), + ], +) +async def test_alias_mutations_invalidate_only_auth_tables( + cache_transactions: SimpleNamespace, + alias_type: str, + operation: str, + model: type[CacheTestBase], + id_column: str, + name_column: str, + tracked: bool, +) -> None: + env = cache_transactions + if alias_type == 'orm': + target = aliased(model) + target_id = getattr(target, id_column) + else: + target = model.__table__.alias() + if alias_type == 'nested': + target = target.alias() + target_id = target.c[id_column] + statement = update(target).values({name_column: 'changed'}) if operation == 'update' else delete(target) + async with env.sessions.begin() as db: + result = await db.execute(statement.where(target_id == 1)) + assert result.rowcount == 1 + assert env.invalidations == [] + if tracked: + assert len(env.invalidations) == 1 + assert env.invalidations[0][0] is None + else: + assert env.invalidations == [] + async with env.sessions() as db: + row = await db.get(model, 1) + if operation == 'update': + assert getattr(row, name_column) == 'changed' + else: + assert row is None + + +@pytest.mark.asyncio +async def test_alias_target_with_original_table_filter_invalidates_all_users( + cache_transactions: SimpleNamespace, +) -> None: + env = cache_transactions + users = CacheUser.__table__ + target = users.alias('target') + statement = update(target).where(users.c.user_id == 1).values(user_name=target.c.user_name + '-changed') + async with env.sessions.begin() as db: + with pytest.warns(SAWarning, match='cartesian product'): + await db.execute(statement) + assert env.invalidations == [(None, ['first-changed', 'second-changed'])] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('wrapper', ['columns', 'orm_text', 'orm_core']) +@pytest.mark.parametrize('finish', ['commit', 'rollback']) +async def test_wrapped_write_invalidates_only_after_commit( + cache_transactions: SimpleNamespace, wrapper: str, finish: str +) -> None: + env = cache_transactions + raw_sql = text("UPDATE sys_user SET user_name = 'changed' WHERE user_id = 1 RETURNING user_id, user_name") + if wrapper == 'columns': + statement = raw_sql.columns(user_id=Integer, user_name=String) + elif wrapper == 'orm_text': + statement = select(CacheUser).from_statement(raw_sql) + else: + statement = select(CacheUser).from_statement( + update(CacheUser).where(CacheUser.user_id == 1).values(user_name='changed').returning(CacheUser) + ) + async with env.sessions() as db: + result = await db.execute(statement) + assert len(result.all()) == 1 + assert env.invalidations == [] + await getattr(db, finish)() + expected_names = ['changed', 'second'] if finish == 'commit' else ['first', 'second'] + assert env.invalidations == ([(None, expected_names)] if finish == 'commit' else []) + async with env.sessions() as db: + assert (await db.scalars(select(CacheUser.user_name).order_by(CacheUser.user_id))).all() == expected_names + + +@pytest.mark.asyncio +@pytest.mark.parametrize('wrapper', ['columns', 'orm_text', 'orm_select', 'select_cte', 'text_cte', 'where_fragment']) +async def test_readonly_wrappers_and_sql_fragments_do_not_invalidate( + cache_transactions: SimpleNamespace, wrapper: str +) -> None: + env = cache_transactions + raw_sql = text('SELECT user_id, user_name FROM sys_user WHERE user_id = 1') + if wrapper == 'columns': + statement = raw_sql.columns(user_id=Integer, user_name=String) + elif wrapper == 'orm_text': + statement = select(CacheUser).from_statement(raw_sql) + elif wrapper == 'orm_select': + statement = select(CacheUser).from_statement(select(CacheUser).where(CacheUser.user_id == 1)) + elif wrapper == 'select_cte': + statement = select(select(CacheUser).where(CacheUser.user_id == 1).cte('users')) + elif wrapper == 'text_cte': + statement = select(raw_sql.columns(user_id=Integer, user_name=String).cte('users')) + else: + statement = select(CacheUser).where(text('user_id = 1')) + async with env.sessions.begin() as db: + result = await db.execute(statement) + assert len(result.all()) == 1 + assert env.invalidations == [] + + +@pytest.mark.parametrize('operation', ['insert', 'update', 'delete']) +@pytest.mark.parametrize('outer', ['select', 'user_update', 'job_update']) +@pytest.mark.parametrize('auth_table', [True, False]) +def test_nested_dml_cte_invalidates_auth_writes_conservatively( + monkeypatch: pytest.MonkeyPatch, operation: str, outer: str, auth_table: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', True) + target = CacheUser.__table__ if auth_table else CacheJob.__table__ + id_column, name_column = ('user_id', 'user_name') if auth_table else ('job_id', 'job_name') + if operation == 'insert': + mutation = insert(target).values({id_column: 3, name_column: 'created'}) + elif operation == 'update': + mutation = update(target).where(target.c[id_column] == SECOND_USER_ID).values({name_column: 'changed'}) + else: + mutation = delete(target).where(target.c[id_column] == SECOND_USER_ID) + nested = mutation.returning(target.c[id_column]).cte('nested_change') + if outer == 'select': + statement = select(nested) + elif outer == 'user_update': + statement = update(CacheUser.__table__).where(CacheUser.user_id == 1).values(user_name='outer').add_cte(nested) + else: + statement = update(CacheJob.__table__).where(CacheJob.job_id == 1).values(job_name='outer').add_cte(nested) + assert str(statement.compile(dialect=postgresql.dialect())).startswith('WITH nested_change AS') + with CurrentUserCacheSession() as db, db.begin() as transaction: + state = SimpleNamespace( + statement=statement, + session=db, + invoke_statement=Mock(return_value=object()), + is_insert=False, + is_update=outer != 'select', + is_delete=False, + is_orm_statement=False, + parameters=None, + execution_options={}, + ) + _track_current_user_statement(state) + if auth_table or outer == 'user_update': + assert db.info[CurrentUserCacheSession._CHANGE_KEY] == {transaction: None if auth_table else {1}} + else: + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + transaction.rollback() + + +@pytest.mark.parametrize( + 'sql', + [ + "EXPLAIN ANALYZE UPDATE sys_user SET user_name = 'changed' WHERE user_id = 1", + "EXPLAIN(ANALYZE TRUE) UPDATE sys_user SET user_name = 'changed' WHERE user_id = 1", + ], +) +def test_explain_analyze_write_requires_invalidation(monkeypatch: pytest.MonkeyPatch, sql: str) -> None: + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', True) + with CurrentUserCacheSession() as db, db.begin() as transaction: + state = SimpleNamespace( + statement=text(sql), + session=db, + invoke_statement=Mock(return_value=object()), + is_insert=False, + is_update=False, + is_delete=False, + ) + _track_current_user_statement(state) + assert db.info[CurrentUserCacheSession._CHANGE_KEY] == {transaction: None} + transaction.rollback() + + +@pytest.mark.parametrize( + 'model, id_column, name_column, joined_model, joined_id_column, tracked', + [ + (CacheUser, 'user_id', 'user_name', CacheJob, 'job_id', True), + (CacheUser, 'user_id', 'user_name', CacheUser, 'user_id', True), + (CacheRole, 'role_id', 'role_name', CacheJob, 'job_id', True), + (CacheJob, 'job_id', 'job_name', CacheJob, 'job_id', False), + ], +) +def test_join_update_tracks_auth_tables_after_sql_execution( + monkeypatch: pytest.MonkeyPatch, + model: type[CacheTestBase], + id_column: str, + name_column: str, + joined_model: type[CacheTestBase], + joined_id_column: str, + tracked: bool, +) -> None: + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', True) + target = model.__table__ + joined = joined_model.__table__.alias('joined') + statement = ( + update(target.join(joined, target.c[id_column] == joined.c[joined_id_column])) + .where(target.c[id_column] == 1) + .values({target.c[name_column]: 'changed'}) + ) + assert ' INNER JOIN ' in str(statement.compile(dialect=mysql.dialect())) + result = object() + with CurrentUserCacheSession() as db, db.begin() as transaction: + state = SimpleNamespace( + statement=statement, + session=db, + invoke_statement=Mock(return_value=result), + is_insert=False, + is_update=True, + is_delete=False, + is_orm_statement=False, + parameters=None, + execution_options={}, + ) + assert _track_current_user_statement(state) is result + state.invoke_statement.assert_called_once_with() + if tracked: + assert db.info[CurrentUserCacheSession._CHANGE_KEY] == {transaction: None} + else: + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + transaction.rollback() + + +@pytest.mark.asyncio +async def test_shared_change_overrides_user_scope(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='changed')) + role = await db.get(CacheRole, 1) + role.role_name = 'changed' + assert env.invalidations == [(None, ['changed', 'second'])] + + +@pytest.mark.asyncio +async def test_readonly_and_unchanged_orm_object_do_not_invalidate(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + user = await db.get(CacheUser, 1) + user.user_name = 'first' + await db.execute(text('SELECT 1')) + assert env.invalidations == [] + + +@pytest.mark.asyncio +async def test_disabled_cache_does_not_register_mutations( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', False) + async with cache_transactions.sessions.begin() as db: + await db.execute(update(CacheUser).values(user_name=CacheUser.user_name + '-new')) + user = await db.get(CacheUser, 1) + user.user_name = 'changed' + assert cache_transactions.invalidations == [] + + +@pytest.mark.asyncio +async def test_failed_commit_does_not_invalidate(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions() as db: + user = await db.get(CacheUser, 1) + user.user_name = 'second' + with pytest.raises(IntegrityError): + await db.commit() + await db.rollback() + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + assert env.invalidations == [] + + +@pytest.mark.asyncio +async def test_cancellation_before_commit_rolls_back(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + entered = asyncio.Event() + + async def mutate() -> None: + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='cancelled')) + entered.set() + await asyncio.Event().wait() + + task = asyncio.create_task(mutate()) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert env.invalidations == [] + async with env.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'first' + + +@pytest.mark.asyncio +async def test_cancellation_after_commit_does_not_undo_committed_data( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + env = cache_transactions + entered = asyncio.Event() + + async def invalidate(session: CurrentUserCacheSession, user_ids: set[int] | None) -> None: + assert user_ids == {1} + entered.set() + await asyncio.Event().wait() + + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', invalidate) + + async def mutate() -> None: + async with env.sessions() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + await db.commit() + + task = asyncio.create_task(mutate()) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + async with env.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + assert env.invalidations == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('table_name', list(SHARED_TABLES)) +async def test_all_shared_permission_tables_invalidate_global( + cache_transactions: SimpleNamespace, table_name: str +) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + await db.execute(insert(SHARED_TABLES[table_name]).values(id=1, name='changed')) + assert env.invalidations == [(None, ['first', 'second'])] + + +@pytest.mark.asyncio +async def test_multiple_savepoint_levels_preserve_parent_changes(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='outer')) + nested = await db.begin_nested() + async with db.begin_nested(): + await db.execute(update(CacheRole).values(role_name='changed')) + await nested.rollback() + assert env.invalidations == [({1}, ['outer', 'second'])] + + +@pytest.mark.asyncio +async def test_session_can_be_reused_after_commit_and_rollback(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='first-commit')) + await db.commit() + await db.execute(update(CacheRole).values(role_name='discard')) + await db.rollback() + await db.execute(update(CacheUser).where(CacheUser.user_id == SECOND_USER_ID).values(user_name='second-commit')) + await db.commit() + assert env.invalidations == [({1}, ['first-commit', 'second']), ({2}, ['first-commit', 'second-commit'])] + + +@pytest.mark.asyncio +async def test_disabled_cache_does_not_compile_tracking_sql( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + env = cache_transactions + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', False) + + def unexpected_scope(*args: Any) -> None: + pytest.fail('关闭缓存后不应分析用户变更范围') + + monkeypatch.setattr(CurrentUserCacheSession, '_where_user_ids', unexpected_scope) + monkeypatch.setattr(CurrentUserCacheSession, '_has_unscoped_write', unexpected_scope) + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='changed')) + assert env.invalidations == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['create', 'invalidate', 'close', 'timeout', 'create_timeout', 'close_timeout']) +async def test_cache_failure_does_not_turn_committed_write_into_error( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + env = cache_transactions + redis = SimpleNamespace(aclose=AsyncMock()) + create = AsyncMock(return_value=redis) + invalidate = AsyncMock(return_value=True) + if failure == 'create': + create.side_effect = RedisConnectionError('unavailable') + elif failure == 'invalidate': + invalidate.side_effect = RedisConnectionError('unavailable') + elif failure == 'close': + redis.aclose.side_effect = RedisConnectionError('unavailable') + else: + + async def blocked(*args: Any) -> None: + await asyncio.Event().wait() + + if failure == 'create_timeout': + create.side_effect = blocked + elif failure == 'close_timeout': + redis.aclose.side_effect = blocked + else: + invalidate.side_effect = blocked + monkeypatch.setattr(RedisUtil, 'create_redis_pool', create) + monkeypatch.setattr(CurrentUserCacheUtil, 'invalidate', invalidate) + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', _ORIGINAL_INVALIDATE) + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + async with env.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + if failure not in ('create', 'create_timeout'): + redis.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_sync_session_commit_survives_missing_greenlet_and_closes_coroutine( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, recwarn: pytest.WarningsRecorder +) -> None: + engine = create_engine(cache_transactions.engine.url.set(drivername='sqlite')) + create = AsyncMock() + monkeypatch.setattr(RedisUtil, 'create_redis_pool', create) + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', _ORIGINAL_INVALIDATE) + try: + with CurrentUserCacheSession(bind=engine) as db: + db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + db.commit() + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + async with cache_transactions.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + create.assert_not_awaited() + assert not [warning for warning in recwarn if issubclass(warning.category, RuntimeWarning)] + finally: + engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('stage', ['metadata', 'invalidate']) +async def test_listener_failure_does_not_change_committed_database_result( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, stage: str +) -> None: + invalidate = AsyncMock(side_effect=RuntimeError('cache listener unavailable')) + async with cache_transactions.sessions() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + with monkeypatch.context() as patch: + if stage == 'metadata': + patch.setattr( + db.sync_session, 'get_nested_transaction', Mock(side_effect=RuntimeError('invalid state')) + ) + else: + patch.setattr(CurrentUserCacheSession, '_invalidate_cache', invalidate) + await db.commit() + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + async with cache_transactions.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + if stage == 'invalidate': + invalidate.assert_awaited_once_with({1}) + assert cache_transactions.invalidations == [] + + +@pytest.mark.asyncio +async def test_commit_invalidation_and_cleanup_support_python310( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delattr(asyncio, 'timeout', raising=False) + redis = SimpleNamespace(aclose=AsyncMock()) + create = AsyncMock(return_value=redis) + invalidate = AsyncMock(return_value=True) + monkeypatch.setattr(RedisUtil, 'create_redis_pool', create) + monkeypatch.setattr(CurrentUserCacheUtil, 'invalidate', invalidate) + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', _ORIGINAL_INVALIDATE) + async with cache_transactions.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + create.assert_awaited_once_with(log_enabled=False, log_start_enabled=False) + invalidate.assert_awaited_once_with(redis, CurrentUserCacheUtil.namespace_for_bind(cache_transactions.engine), {1}) + redis.aclose.assert_awaited_once() + async with cache_transactions.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + + +@pytest.mark.asyncio +async def test_unknown_namespace_does_not_invalidate_another_database( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + create = AsyncMock() + monkeypatch.setattr(RedisUtil, 'create_redis_pool', create) + monkeypatch.setattr(CurrentUserCacheUtil, 'namespace_for_bind', lambda bind: None) + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', _ORIGINAL_INVALIDATE) + async with cache_transactions.sessions.begin() as db: + await db.execute(update(CacheRole).values(role_name='changed')) + create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancellation_closes_cache_client_and_preserves_commit( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + redis = SimpleNamespace(aclose=AsyncMock()) + monkeypatch.setattr(RedisUtil, 'create_redis_pool', AsyncMock(return_value=redis)) + monkeypatch.setattr(CurrentUserCacheUtil, 'invalidate', AsyncMock(side_effect=asyncio.CancelledError)) + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', _ORIGINAL_INVALIDATE) + async with cache_transactions.sessions() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + with pytest.raises(asyncio.CancelledError): + await db.commit() + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + redis.aclose.assert_awaited_once() + async with cache_transactions.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('cancellation', ['scope', 'task_group', 'deadline']) +@pytest.mark.parametrize('stage', ['invalidate', 'close']) +async def test_anyio_cancellation_finishes_cache_cleanup_and_preserves_commit( # noqa: PLR0915 + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, cancellation: str, stage: str +) -> None: + entered = anyio.Event() + release_close = anyio.Event() + closed = anyio.Event() + cancelled = anyio.Event() + cleanup_cancelled = anyio.Event() + cancel_scope = None + + async def invalidate(*args: Any) -> None: + if stage != 'invalidate': + return + entered.set() + if cancellation == 'scope': + cancel_scope.cancel() + elif cancellation == 'deadline': + cancel_scope.deadline = anyio.current_time() + 0.01 + await anyio.sleep_forever() + + async def close() -> None: + if stage == 'close': + entered.set() + if cancellation == 'scope': + cancel_scope.cancel() + elif cancellation == 'deadline': + cancel_scope.deadline = anyio.current_time() + else: + await release_close.wait() + await anyio.lowlevel.checkpoint() + closed.set() + + redis = SimpleNamespace(aclose=AsyncMock(side_effect=close)) + + async def invalidate_cache(session: CurrentUserCacheSession, user_ids: set[int] | None) -> None: + try: + await _ORIGINAL_INVALIDATE(session, user_ids) + except anyio.get_cancelled_exc_class(): + cleanup_cancelled.set() + raise + + monkeypatch.setattr(RedisUtil, 'create_redis_pool', AsyncMock(return_value=redis)) + monkeypatch.setattr(CurrentUserCacheUtil, 'invalidate', AsyncMock(side_effect=invalidate)) + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', invalidate_cache) + async with cache_transactions.sessions() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + + async def commit() -> None: + try: + await db.commit() + except anyio.get_cancelled_exc_class(): + cancelled.set() + raise + + if cancellation == 'scope': + with anyio.CancelScope() as cancel_scope: + await commit() + assert cancel_scope.cancelled_caught + elif cancellation == 'task_group': + async with anyio.create_task_group() as group: + group.start_soon(commit) + await entered.wait() + group.cancel_scope.cancel() + release_close.set() + else: + with pytest.raises(TimeoutError), anyio.fail_after(None) as cancel_scope: + await commit() + assert cancelled.is_set() + assert cleanup_cancelled.is_set() + assert closed.is_set() + assert CurrentUserCacheSession._CHANGE_KEY not in db.info + redis.aclose.assert_awaited_once() + async with cache_transactions.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'committed' + + +@pytest.mark.asyncio +async def test_concurrent_commits_invalidate_each_user_without_shared_state( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + entered = asyncio.Event() + release = asyncio.Event() + invalidations = [] + + async def invalidate(session: CurrentUserCacheSession, user_ids: set[int] | None) -> None: + if user_ids == {1}: + entered.set() + await release.wait() + invalidations.append(user_ids) + + monkeypatch.setattr(CurrentUserCacheSession, '_invalidate_cache', invalidate) + + async def mutate(user_id: int) -> None: + async with cache_transactions.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == user_id).values(user_name=f'user-{user_id}')) + + first = asyncio.create_task(mutate(1)) + await entered.wait() + await mutate(SECOND_USER_ID) + release.set() + await first + assert invalidations == [{2}, {1}] + + +@pytest.mark.asyncio +async def test_orm_bulk_user_update_uses_primary_key_scope(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + await db.execute(update(CacheUser), [{'user_id': 1, 'user_name': 'changed'}]) + assert env.invalidations == [({1}, ['changed', 'second'])] + + +@pytest.mark.asyncio +async def test_core_bulk_update_cannot_assume_parameter_is_a_primary_key_filter( + cache_transactions: SimpleNamespace, +) -> None: + env = cache_transactions + async with env.sessions.begin() as db: + await db.execute( + update(CacheUser.__table__).where(CacheUser.user_id == 1), [{'user_id': 3, 'user_name': 'changed'}] + ) + assert env.invalidations[0][0] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('assignment', ['bind', 'expression', 'ordered', 'parameters', 'orm_bulk']) +async def test_user_relation_move_invalidates_old_and_new_users( + cache_transactions: SimpleNamespace, assignment: str +) -> None: + env = cache_transactions + relation = CacheUserRole.__table__ + async with env.engine.begin() as connection: + await connection.execute(insert(relation).values(user_id=1, role_id=1)) + statement = update(relation).where(relation.c.user_id == 1) + parameters = None + if assignment == 'bind': + statement = statement.values(user_id=bindparam('next_id')) + parameters = {'next_id': SECOND_USER_ID} + elif assignment == 'expression': + statement = statement.values(user_id=relation.c.user_id + 1) + elif assignment == 'ordered': + statement = statement.ordered_values((relation.c.user_id, bindparam('next_id'))) + parameters = {'next_id': SECOND_USER_ID} + elif assignment == 'parameters': + statement = statement.values(role_id=1) + parameters = {'user_id': SECOND_USER_ID} + else: + statement = update(CacheUserRole).values(user_id=bindparam('next_id')) + parameters = [{'user_id': 1, 'role_id': 1, 'next_id': SECOND_USER_ID}] + async with env.sessions.begin() as db: + await db.execute(statement, parameters) + assert env.invalidations == [(None, ['first', 'second'])] + async with env.sessions() as db: + assert await db.get(CacheUserRole, (1, 1)) is None + assert await db.get(CacheUserRole, (SECOND_USER_ID, 1)) is not None + + +@pytest.mark.asyncio +async def test_core_tuple_parameters_commit_with_precise_user_scope(cache_transactions: SimpleNamespace) -> None: + env = cache_transactions + users = CacheUser.__table__ + statement = update(users).where(users.c.user_id == bindparam('target_id')).values(user_name=bindparam('next_name')) + parameters = ({'target_id': 1, 'next_name': 'changed-first'}, {'target_id': 2, 'next_name': 'changed-second'}) + async with env.sessions.begin() as db: + await db.execute(statement, parameters) + assert env.invalidations == [({1, 2}, ['changed-first', 'changed-second'])] + + +@pytest.mark.asyncio +async def test_scope_analysis_failure_preserves_sql_result_and_invalidates_globally( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + env = cache_transactions + + def failed_scope(*args: Any) -> None: + raise ValueError('unexpected parameter format') + + monkeypatch.setattr(CurrentUserCacheSession, '_where_user_ids', failed_scope) + async with env.sessions.begin() as db: + result = await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='committed')) + assert result.rowcount == 1 + assert env.invalidations == [(None, ['committed', 'second'])] + + +@pytest.mark.asyncio +async def test_sql_execution_failure_still_propagates_before_scope_analysis( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + env = cache_transactions + + def unexpected_scope(*args: Any) -> None: + pytest.fail('SQL执行失败后不应分析缓存变更范围') + + monkeypatch.setattr(CurrentUserCacheSession, '_where_user_ids', unexpected_scope) + with pytest.raises(IntegrityError): + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='second')) + assert env.invalidations == [] + async with env.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'first' + + +@pytest.mark.asyncio +async def test_scope_analysis_cancellation_is_not_swallowed( + cache_transactions: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + env = cache_transactions + + def cancelled_scope(*args: Any) -> None: + raise asyncio.CancelledError + + monkeypatch.setattr(CurrentUserCacheSession, '_where_user_ids', cancelled_scope) + with pytest.raises(asyncio.CancelledError): + async with env.sessions.begin() as db: + await db.execute(update(CacheUser).where(CacheUser.user_id == 1).values(user_name='cancelled')) + assert env.invalidations == [] + async with env.sessions() as db: + assert (await db.get(CacheUser, 1)).user_name == 'first' diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_cached_session_races.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_cached_session_races.py new file mode 100644 index 000000000..cb5551277 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_cached_session_races.py @@ -0,0 +1,229 @@ +import asyncio +from types import SimpleNamespace +from typing import Any +from unittest.mock import Mock + +import pytest +from fastapi import Request +from redis.exceptions import TimeoutError as RedisTimeoutError + +from common.context import RequestContext +from config.env import AppConfig +from exceptions.exception import AuthException +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.login_service import _SESSION_RENEW_SCRIPT, LoginService +from plugins.core.runtime.browser_session import ( + COOKIE_NAME_PREFIX, + authenticate_browser_session, + issue_browser_session, +) +from tests.module_admin.service.test_current_user_cache_integration import cache_env # noqa: F401 +from tests.module_admin.service.test_current_user_resolution import ( + REGULAR_QUERY_COUNT, + USER_ID, + resolution_env, # noqa: F401 +) +from tests.utils.test_current_user_cache_util import cache_redis, redis_socket # noqa: F401 +from utils.cache_util import CurrentUserCacheUtil + +_FINAL_PLUGIN_READ = 2 + + +async def _prepare_cached_session( + env: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, same_time_login: bool +) -> tuple[Request, str, str]: + """使用真实鉴权预热快照,并设置当前登录模式对应的会话键。""" + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + token = env.headers[USER_ID]['Authorization'].removeprefix('Bearer ') + token_id = f'session-{USER_ID}' if same_time_login else str(USER_ID) + token_key = f'access_token:{token_id}' + await env.redis.set(token_key, token, ex=60) + response = await env.client.get('/profile', headers=env.headers[USER_ID]) + assert response.json()['permissions'] == ['demo:read'] + assert len(env.statements) == REGULAR_QUERY_COUNT + request = Request( + { + 'type': 'http', + 'app': env.app, + 'method': 'GET', + 'scheme': 'http', + 'path': '/apps/cache_test/api/info', + 'headers': [(b'host', b'test')], + } + ) + return request, token, token_key + + +async def _resolve_cached_session( + env: SimpleNamespace, request: Request, token: str, for_plugin: bool +) -> CurrentUserModel: + """通过新数据库会话进入真实缓存命中及最终会话校验路径。""" + resolve = LoginService.get_current_user_for_plugin_session if for_plugin else LoginService.get_current_user + async with env.sessions() as db: + return await resolve(request, token, db) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +@pytest.mark.parametrize('for_plugin', [True, False]) +@pytest.mark.parametrize('change', ['logout', 'new_login', 'expired']) +async def test_cached_request_rejects_session_changed_after_snapshot_read( + cache_env: SimpleNamespace, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, + same_time_login: bool, + for_plugin: bool, + change: str, +) -> None: + request, token, token_key = await _prepare_cached_session(cache_env, monkeypatch, same_time_login) + loaded, resume = asyncio.Event(), asyncio.Event() + original_eval = cache_env.redis.eval + + async def pause_snapshot(script: str, *args: Any) -> Any: + result = await original_eval(script, *args) + if script == CurrentUserCacheUtil._READ_SCRIPT: + assert result[4] + loaded.set() + await resume.wait() + return result + + monkeypatch.setattr(cache_env.redis, 'eval', pause_snapshot) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + pending = asyncio.create_task(_resolve_cached_session(cache_env, request, token, for_plugin)) + replacement_deadline = None + try: + await asyncio.wait_for(loaded.wait(), timeout=2) + if change == 'logout': + await LoginService.logout_services(request, token_key.removeprefix('access_token:'), token) + elif change == 'new_login': + await cache_env.redis.set(token_key, 'new-login-token', ex=60) + replacement_deadline = await cache_env.redis.pexpiretime(token_key) + else: + await cache_env.redis.pexpireat(token_key, 1) + resume.set() + with pytest.raises(AuthException): + await pending + finally: + resume.set() + if not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + + set_user.assert_not_called() + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == (1 if for_plugin else 2) + if change == 'new_login': + assert await cache_env.redis.get(token_key) == 'new-login-token' + assert await cache_env.redis.pexpiretime(token_key) == replacement_deadline + else: + assert await cache_env.redis.get(token_key) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +@pytest.mark.parametrize('for_plugin', [True, False]) +@pytest.mark.parametrize('failure', ['timeout', 'cancel']) +async def test_cached_final_session_check_failure_never_sets_authenticated_context( # noqa: PLR0915 + cache_env: SimpleNamespace, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, + same_time_login: bool, + for_plugin: bool, + failure: str, +) -> None: + request, token, token_key = await _prepare_cached_session(cache_env, monkeypatch, same_time_login) + await cache_env.redis.pexpire(token_key, 1000) + previous_deadline = await cache_env.redis.pexpiretime(token_key) + checked, resume = asyncio.Event(), asyncio.Event() + original_eval, original_get = cache_env.redis.eval, cache_env.redis.get + session_reads = 0 + + async def fail_after_check() -> None: + checked.set() + if failure == 'timeout': + raise RedisTimeoutError('session response unavailable') + await resume.wait() + + async def eval_then_fail(script: str, *args: Any) -> Any: + result = await original_eval(script, *args) + if script == CurrentUserCacheUtil._READ_SCRIPT: + assert result[4] + if script == _SESSION_RENEW_SCRIPT: + assert result == 1 + await fail_after_check() + return result + + async def get_then_fail(key: str) -> Any: + nonlocal session_reads + result = await original_get(key) + if for_plugin and key == token_key: + session_reads += 1 + if session_reads == _FINAL_PLUGIN_READ: + assert result == token + await fail_after_check() + return result + + monkeypatch.setattr(cache_env.redis, 'eval', eval_then_fail) + monkeypatch.setattr(cache_env.redis, 'get', get_then_fail) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + pending = asyncio.create_task(_resolve_cached_session(cache_env, request, token, for_plugin)) + try: + await asyncio.wait_for(checked.wait(), timeout=2) + if failure == 'cancel': + pending.cancel() + with pytest.raises(asyncio.CancelledError if failure == 'cancel' else RedisTimeoutError): + await pending + finally: + resume.set() + if not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + + set_user.assert_not_called() + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert await original_get(token_key) == token + deadline = await cache_env.redis.pexpiretime(token_key) + if for_plugin: + assert cache_env.renew_count() == 1 + assert deadline == previous_deadline + else: + # Redis已经完成续期但未收到成功响应时,当前请求仍不能通过鉴权。 + assert cache_env.renew_count() == _FINAL_PLUGIN_READ + assert deadline > previous_deadline + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +async def test_browser_cookie_uses_cached_permissions_without_renewal_and_rejects_logout( + cache_env: SimpleNamespace, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, + same_time_login: bool, +) -> None: + request, token, token_key = await _prepare_cached_session(cache_env, monkeypatch, same_time_login) + cookie, _, _ = await issue_browser_session( + request, plugin_id='cache_test', version='1.0.0', main_token=token, user_id=USER_ID + ) + request = Request( + { + **request.scope, + 'headers': [*request.scope['headers'], (b'cookie', f'{COOKIE_NAME_PREFIX}cache_test={cookie}'.encode())], + } + ) + deadline = await cache_env.redis.pexpiretime(token_key) + async with cache_env.sessions() as db: + user = await authenticate_browser_session(request, plugin_id='cache_test', version='1.0.0', query_db=db) + assert user.permissions == ['demo:read'] + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == 1 + assert await cache_env.redis.pexpiretime(token_key) == deadline + + await LoginService.logout_services(request, token_key.removeprefix('access_token:'), token) + set_user = Mock() + monkeypatch.setattr(RequestContext, 'set_current_user', set_user) + async with cache_env.sessions() as db: + with pytest.raises(LookupError, match='主登录会话已失效'): + await authenticate_browser_session(request, plugin_id='cache_test', version='1.0.0', query_db=db) + set_user.assert_not_called() + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == 1 + assert await cache_env.redis.get(token_key) is None diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_cache_integration.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_cache_integration.py new file mode 100644 index 000000000..824c44ce8 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_cache_integration.py @@ -0,0 +1,501 @@ +import asyncio +from collections.abc import AsyncIterator +from types import SimpleNamespace +from typing import Annotated, Any +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from fastapi import Depends, Request +from httpx import ASGITransport, AsyncClient +from redis.asyncio import Redis +from redis.exceptions import ConnectionError as RedisConnectionError +from sqlalchemy import delete, update +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from common.aspect.db_session import DBSessionDependency, get_db_session_provider +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.constant import HttpStatusConstant +from common.context import RequestContext +from config.database import CurrentUserCacheSession, create_async_session_factory +from config.env import AppConfig +from config.get_redis import RedisUtil +from module_admin.entity.do.dept_do import SysDept +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.post_do import SysPost +from module_admin.entity.do.role_do import SysRole, SysRoleMenu +from module_admin.entity.do.user_do import SysUser, SysUserPost, SysUserRole +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.login_service import _SESSION_RENEW_SCRIPT, LoginService +from tests.module_admin.service.test_current_user_resolution import ( + ADMIN_ID, + ADMIN_QUERY_COUNT, + REGULAR_QUERY_COUNT, + TWO_AUTHENTICATIONS, + USER_ID, + resolution_env, # noqa: F401 +) +from tests.utils.test_current_user_cache_util import cache_redis, redis_socket # noqa: F401 +from utils.cache_util import CurrentUserCacheUtil + +THREE_AUTHENTICATIONS = 3 +FOUR_AUTHENTICATIONS = 4 + + +@pytest_asyncio.fixture +async def cache_env( + resolution_env: SimpleNamespace, # noqa: F811 + cache_redis: Redis, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, +) -> AsyncIterator[SimpleNamespace]: + """复用真实鉴权路由和数据库,缓存及会话命令均由隔离Redis执行。""" + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', True) + monkeypatch.setattr(AppConfig, 'app_current_user_cache_ttl_seconds', 5) + for key, value in resolution_env.redis.values.items(): + await cache_redis.set(key, value, ex=3600) + resolution_env.app.state.redis = cache_redis + engine = resolution_env.sessions.kw['bind'] + sessions = create_async_session_factory(engine) + sessions.configure(info={CurrentUserCacheSession._REDIS_KEY: cache_redis}) + namespace = CurrentUserCacheUtil.namespace_for_bind(engine) + assert namespace is not None + calls = [] + original_eval = cache_redis.eval + + async def record_eval(script: str, *args: Any) -> Any: + calls.append(script) + return await original_eval(script, *args) + + async def session_dependency() -> AsyncIterator[AsyncSession]: + async with sessions() as db: + yield db + + monkeypatch.setattr(cache_redis, 'eval', record_eval) + monkeypatch.setattr( + RedisUtil, 'create_redis_pool', AsyncMock(side_effect=AssertionError('应用会话应复用已有Redis客户端')) + ) + resolution_env.app.dependency_overrides[get_db_session_provider(None)] = session_dependency + yield SimpleNamespace( + **{ + **vars(resolution_env), + 'redis': cache_redis, + 'sessions': sessions, + 'namespace': namespace, + 'calls': calls, + 'renew_count': lambda: calls.count(_SESSION_RENEW_SCRIPT), + } + ) + + +@pytest.mark.asyncio +async def test_cache_hit_avoids_sql_and_still_renews_the_session(cache_env: SimpleNamespace) -> None: + first = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert first.status_code == HttpStatusConstant.SUCCESS + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + snapshot_key = CurrentUserCacheUtil._keys(cache_env.namespace, USER_ID)[2] + deadline = await cache_env.redis.hget(snapshot_key, 'deadline') + + second = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert second.json() == first.json() + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == TWO_AUTHENTICATIONS + assert await cache_env.redis.hget(snapshot_key, 'deadline') == deadline + assert 'private-password-hash' not in await cache_env.redis.hget(snapshot_key, 'payload') + + +@pytest.mark.asyncio +async def test_cache_hit_recomputes_password_policy( + cache_env: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + first = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert not first.json()['isPasswordExpired'] + await cache_env.redis.mset( + { + 'sys_config:sys.account.chrtype': '4', + 'sys_config:sys.account.initPasswordModify': '1', + 'sys_config:sys.account.passwordValidateDays': '1', + } + ) + monkeypatch.setattr(AppConfig, 'app_timezone', 'UTC') + + second = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert second.json()['pwdChrtype'] == '4' + assert second.json()['isDefaultModifyPwd'] + assert second.json()['isPasswordExpired'] + assert second.json()['appTimezone'] == 'UTC' + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + + +@pytest.mark.asyncio +async def test_concurrent_cache_hits_keep_users_and_context_separate(cache_env: SimpleNamespace) -> None: + for user_id in (ADMIN_ID, USER_ID): + await cache_env.client.get('/profile', headers=cache_env.headers[user_id]) + initial_queries = len(cache_env.statements) + admin, user = await asyncio.gather( + cache_env.client.get('/profile', headers=cache_env.headers[ADMIN_ID]), + cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]), + ) + + assert admin.json()['user']['userId'] == ADMIN_ID + assert admin.json()['permissions'] == ['*:*:*'] + assert user.json()['user']['userId'] == USER_ID + assert user.json()['permissions'] == ['demo:read'] + assert initial_queries == ADMIN_QUERY_COUNT + REGULAR_QUERY_COUNT + assert len(cache_env.statements) == initial_queries + assert cache_env.renew_count() == FOUR_AUTHENTICATIONS + + +@pytest.mark.asyncio +@pytest.mark.parametrize('replacement', [None, 'new-login-token']) +async def test_cache_hit_never_accepts_revoked_or_replaced_token( + cache_env: SimpleNamespace, replacement: str | None +) -> None: + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + token_key = f'access_token:session-{USER_ID}' + if replacement is None: + await cache_env.redis.delete(token_key) + else: + await cache_env.redis.set(token_key, replacement, ex=60) + + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert response.json()['code'] == HttpStatusConstant.UNAUTHORIZED + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == 1 + assert await cache_env.redis.get(token_key) == replacement + + +@pytest.mark.asyncio +async def test_use_cache_false_reauthenticates_even_when_both_calls_hit_cache(cache_env: SimpleNamespace) -> None: + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + @cache_env.app.get('/revalidate-cached', dependencies=[PreAuthDependency()]) + async def revalidate( + first: Annotated[CurrentUserModel, CurrentUserDependency()], + second: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + ) -> dict: + return {'sameUser': first is second, 'contextIsFresh': RequestContext.get_current_user() is second} + + response = await cache_env.client.get('/revalidate-cached', headers=cache_env.headers[USER_ID]) + + assert response.json() == {'sameUser': False, 'contextIsFresh': True} + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == THREE_AUTHENTICATIONS + + +@pytest.mark.asyncio +async def test_cached_use_cache_false_rejects_session_revoked_between_dependencies(cache_env: SimpleNamespace) -> None: + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + async def revoke(current_user: Annotated[CurrentUserModel, CurrentUserDependency()]) -> None: + await cache_env.redis.delete(f'access_token:session-{current_user.user.user_id}') + + @cache_env.app.get('/revoke-cached', dependencies=[PreAuthDependency(), Depends(revoke)]) + async def revalidate( + current_user: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + ) -> None: + pytest.fail('已撤销会话不应进入接口') + + response = await cache_env.client.get('/revoke-cached', headers=cache_env.headers[USER_ID]) + + assert response.json()['code'] == HttpStatusConstant.UNAUTHORIZED + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == TWO_AUTHENTICATIONS + + +@pytest.mark.asyncio +async def test_cached_use_cache_false_applies_permission_change_between_dependencies( + cache_env: SimpleNamespace, +) -> None: + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + async def revoke( + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + query_db: Annotated[AsyncSession, DBSessionDependency()], + ) -> None: + await query_db.execute(delete(SysRoleMenu).where(SysRoleMenu.role_id == USER_ID)) + await query_db.commit() + + @cache_env.app.get('/revoke-cached-permission', dependencies=[PreAuthDependency(), Depends(revoke)]) + async def revalidate( + current_user: Annotated[CurrentUserModel, Depends(LoginService.get_current_user, use_cache=False)], + permitted: Annotated[bool, UserInterfaceAuthDependency('demo:read')], + ) -> None: + pytest.fail('权限撤销后不应进入接口') + + response = await cache_env.client.get('/revoke-cached-permission', headers=cache_env.headers[USER_ID]) + + assert response.json()['code'] == HttpStatusConstant.FORBIDDEN + assert len(cache_env.statements) == REGULAR_QUERY_COUNT * 2 + assert cache_env.renew_count() == THREE_AUTHENTICATIONS + + +@pytest.mark.asyncio +async def test_plugin_cache_hit_does_not_renew_host_session(cache_env: SimpleNamespace) -> None: + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + token_key = f'access_token:session-{USER_ID}' + await cache_env.redis.pexpire(token_key, 2000) + ttl = await cache_env.redis.pttl(token_key) + token = cache_env.headers[USER_ID]['Authorization'].removeprefix('Bearer ') + request = Request({'type': 'http', 'app': cache_env.app}) + + async with cache_env.sessions() as db: + current_user = await LoginService.get_current_user_for_plugin_session(request, token, db) + + assert current_user.permissions == ['demo:read'] + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == 1 + assert 0 < await cache_env.redis.pttl(token_key) <= ttl + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', ['disable_user', 'delete_user', 'remove_role', 'disable_role', 'remove_menu']) +async def test_committed_permission_changes_invalidate_cached_authentication( + cache_env: SimpleNamespace, change: str +) -> None: + await cache_env.client.get('/permitted', headers=cache_env.headers[USER_ID]) + async with cache_env.sessions.begin() as db: + if change in ('disable_user', 'delete_user'): + values = {'status': '1'} if change == 'disable_user' else {'del_flag': '2'} + await db.execute(update(SysUser).where(SysUser.user_id == USER_ID).values(**values)) + elif change == 'remove_role': + await db.execute(delete(SysUserRole).where(SysUserRole.user_id == USER_ID)) + elif change == 'disable_role': + await db.execute(update(SysRole).where(SysRole.role_id == USER_ID).values(status='1')) + else: + await db.execute(delete(SysRoleMenu).where(SysRoleMenu.role_id == USER_ID)) + + response = await cache_env.client.get('/permitted', headers=cache_env.headers[USER_ID]) + + expected = ( + HttpStatusConstant.UNAUTHORIZED if change in ('disable_user', 'delete_user') else HttpStatusConstant.FORBIDDEN + ) + assert response.json()['code'] == expected + assert len(cache_env.statements) > REGULAR_QUERY_COUNT + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', ['department', 'data_scope', 'post', 'menu']) +async def test_shared_changes_refresh_all_authorization_fields(cache_env: SimpleNamespace, change: str) -> None: + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + async with cache_env.sessions.begin() as db: + if change == 'department': + await db.execute(update(SysDept).where(SysDept.dept_id == USER_ID).values(ancestors='0,99')) + elif change == 'data_scope': + await db.execute(update(SysRole).where(SysRole.role_id == USER_ID).values(data_scope='5')) + elif change == 'post': + await db.execute(update(SysPost).where(SysPost.post_id == USER_ID).values(status='1')) + else: + await db.execute(update(SysMenu).where(SysMenu.menu_id == USER_ID).values(perms='demo:new')) + + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + payload = response.json() + + if change == 'department': + assert payload['user']['dept']['ancestors'] == '0,99' + elif change == 'data_scope': + assert payload['user']['role'][0]['dataScope'] == '5' + elif change == 'post': + assert payload['user']['postIds'] == '' + else: + assert payload['permissions'] == ['demo:new'] + assert len(cache_env.statements) == REGULAR_QUERY_COUNT * 2 + + +@pytest.mark.asyncio +async def test_existing_transaction_bypasses_cache_without_publishing_uncommitted_permissions( + cache_env: SimpleNamespace, +) -> None: + first = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + async with cache_env.sessions() as db: + await db.execute(update(SysRole).where(SysRole.role_id == USER_ID).values(status='1')) + current_user = await LoginService._get_current_user_snapshot(cache_env.redis, db, USER_ID) + assert current_user.permissions == [] + assert db.in_transaction() + await db.rollback() + queries = len(cache_env.statements) + + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert response.json() == first.json() + assert len(cache_env.statements) == queries + + +@pytest.mark.asyncio +async def test_external_connection_transaction_cannot_publish_uncommitted_user(cache_env: SimpleNamespace) -> None: + first = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + engine = cache_env.sessions.kw['bind'] + async with engine.connect() as connection: + transaction = await connection.begin() + try: + await connection.execute(update(SysUser).where(SysUser.user_id == USER_ID).values(nick_name='Uncommitted')) + async with AsyncSession(bind=connection) as db: + assert not db.in_transaction() + user = await LoginService._get_current_user_snapshot(cache_env.redis, db, USER_ID) + assert user.user.nick_name == 'Uncommitted' + finally: + await transaction.rollback() + queries = len(cache_env.statements) + + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert response.json() == first.json() + assert len(cache_env.statements) == queries + + +@pytest.mark.asyncio +@pytest.mark.parametrize('default_bind', [False, True]) +async def test_multi_bind_sessions_keep_original_database_resolution( + cache_env: SimpleNamespace, default_bind: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + engine = cache_env.sessions.kw['bind'] + secondary = create_async_engine(engine.url) + bindings = dict.fromkeys( + (SysUser, SysDept, SysRole, SysPost, SysMenu, SysUserRole, SysUserPost, SysRoleMenu), engine + ) + if default_bind: + bindings[SysRole] = secondary + read = AsyncMock(side_effect=AssertionError('多绑定会话不应访问用户快照缓存')) + monkeypatch.setattr(CurrentUserCacheUtil, 'read', read) + try: + async with AsyncSession(bind=engine if default_bind else None, binds=bindings) as db: + user = await LoginService._get_current_user_snapshot(cache_env.redis, db, USER_ID) + assert user.user.user_id == USER_ID + assert user.permissions == ['demo:read'] + assert user.roles == ['reader'] + read.assert_not_awaited() + finally: + await secondary.dispose() + + +@pytest.mark.asyncio +async def test_concurrent_permission_commit_prevents_old_query_refilling_cache( + cache_env: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + loaded = asyncio.Event() + release = asyncio.Event() + original = LoginService._get_current_user_from_db + + async def delayed(query_db: AsyncSession, user_id: int) -> CurrentUserModel: + user = await original(query_db, user_id) + loaded.set() + await release.wait() + return user + + monkeypatch.setattr(LoginService, '_get_current_user_from_db', delayed) + pending = asyncio.create_task(cache_env.client.get('/profile', headers=cache_env.headers[USER_ID])) + try: + await asyncio.wait_for(loaded.wait(), timeout=2) + async with cache_env.sessions.begin() as db: + await db.execute(delete(SysRoleMenu).where(SysRoleMenu.role_id == USER_ID)) + release.set() + first = await pending + assert first.json()['permissions'] == ['demo:read'] + finally: + release.set() + if not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + monkeypatch.setattr(LoginService, '_get_current_user_from_db', original) + + response = await cache_env.client.get('/permitted', headers=cache_env.headers[USER_ID]) + + assert response.json()['code'] == HttpStatusConstant.FORBIDDEN + assert len(cache_env.statements) == REGULAR_QUERY_COUNT * 2 + + +@pytest.mark.asyncio +async def test_direct_orm_user_change_invalidates_only_affected_user(cache_env: SimpleNamespace) -> None: + for user_id in (ADMIN_ID, USER_ID): + await cache_env.client.get('/profile', headers=cache_env.headers[user_id]) + async with cache_env.sessions.begin() as db: + user = await db.get(SysUser, USER_ID) + user.nick_name = 'Changed reader' + cache_env.statements.clear() + + admin = await cache_env.client.get('/profile', headers=cache_env.headers[ADMIN_ID]) + assert admin.json()['user']['nickName'] == 'Admin' + assert cache_env.statements == [] + user = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert user.json()['user']['nickName'] == 'Changed reader' + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + + +@pytest.mark.asyncio +async def test_corrupt_cached_payload_falls_back_to_database(cache_env: SimpleNamespace) -> None: + first = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + key = CurrentUserCacheUtil._keys(cache_env.namespace, USER_ID)[2] + await cache_env.redis.hset(key, 'payload', '{invalid-json') + + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert response.json() == first.json() + assert len(cache_env.statements) == REGULAR_QUERY_COUNT * 2 + assert cache_env.renew_count() == TWO_AUTHENTICATIONS + + +@pytest.mark.asyncio +async def test_cache_failure_can_fall_back_without_bypassing_session_check( + cache_env: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + original = cache_env.redis.eval + + async def cache_failure(script: str, *args: Any) -> Any: + if script != _SESSION_RENEW_SCRIPT: + raise RedisConnectionError('snapshot cache unavailable') + return await original(script, *args) + + monkeypatch.setattr(cache_env.redis, 'eval', cache_failure) + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert response.json()['user']['userId'] == USER_ID + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == 1 + + monkeypatch.setattr(cache_env.redis, 'get', AsyncMock(side_effect=RedisConnectionError('session unavailable'))) + async with AsyncClient( + transport=ASGITransport(app=cache_env.app, raise_app_exceptions=False), base_url='http://test' + ) as client: + failed = await client.get('/profile', headers=cache_env.headers[USER_ID]) + assert failed.json()['code'] == HttpStatusConstant.ERROR + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + assert cache_env.renew_count() == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('stage', ['read', 'write']) +async def test_cache_cancellation_propagates_without_renewal_or_context( + cache_env: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, stage: str +) -> None: + original = cache_env.redis.eval + cancel_script = CurrentUserCacheUtil._READ_SCRIPT if stage == 'read' else CurrentUserCacheUtil._WRITE_SCRIPT + + async def cancel(script: str, *args: Any) -> Any: + if script == cancel_script: + raise asyncio.CancelledError + return await original(script, *args) + + monkeypatch.setattr(cache_env.redis, 'eval', cancel) + with pytest.raises(asyncio.CancelledError): + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert cache_env.renew_count() == 0 + expected_queries = 0 if stage == 'read' else REGULAR_QUERY_COUNT + assert len(cache_env.statements) == expected_queries + assert await cache_env.redis.get(f'access_token:session-{USER_ID}') is not None + + +@pytest.mark.asyncio +async def test_cache_disabled_keeps_database_authentication( + cache_env: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', False) + for _ in range(2): + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert response.json()['permissions'] == ['demo:read'] + + assert len(cache_env.statements) == REGULAR_QUERY_COUNT * 2 + assert cache_env.renew_count() == TWO_AUTHENTICATIONS + assert all(script == _SESSION_RENEW_SCRIPT for script in cache_env.calls) diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_cache_mutations.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_cache_mutations.py new file mode 100644 index 000000000..5117ec21c --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_cache_mutations.py @@ -0,0 +1,220 @@ +from types import SimpleNamespace +from typing import Any + +import pytest +from sqlalchemy import delete, update +from sqlalchemy.orm import aliased + +from common.constant import HttpStatusConstant +from module_admin.dao.user_dao import UserDao +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.user_do import SysUser +from module_admin.entity.vo.user_vo import UserPostModel, UserRoleModel +from module_admin.service.user_service import UserService +from plugins.core.management.dao.dao import PluginDao +from tests.module_admin.service.test_current_user_cache_integration import ( + cache_env, # noqa: F401 + cache_redis, # noqa: F401 + redis_socket, # noqa: F401 + resolution_env, # noqa: F401 +) +from tests.module_admin.service.test_current_user_resolution import ADMIN_ID, REGULAR_QUERY_COUNT, USER_ID +from utils.cache_util import CurrentUserCacheUtil +from utils.time_util import TimezoneUtil + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'changes, expected', + [ + ( + {'nick_name': 'Updated reader', 'email': 'reader@example.com', 'phonenumber': '13800000000', 'sex': '1'}, + {'nickName': 'Updated reader', 'email': 'reader@example.com', 'phonenumber': '13800000000', 'sex': '1'}, + ), + ({'avatar': '/profile/avatar/reader.png'}, {'avatar': '/profile/avatar/reader.png'}), + ({'dept_id': None}, {'deptId': None, 'dept': None}), + ({'login_ip': '127.0.0.2'}, {'loginIp': '127.0.0.2'}), + ], +) +async def test_user_dao_updates_refresh_profile_without_invalidating_other_users( + cache_env: SimpleNamespace, # noqa: F811 + changes: dict[str, Any], + expected: dict[str, Any], +) -> None: + for user_id in (ADMIN_ID, USER_ID): + await cache_env.client.get('/profile', headers=cache_env.headers[user_id]) + async with cache_env.sessions.begin() as db: + await UserDao.edit_user_dao(db, {'user_id': USER_ID, **changes}) + cache_env.statements.clear() + + admin = await cache_env.client.get('/profile', headers=cache_env.headers[ADMIN_ID]) + assert admin.json()['user']['nickName'] == 'Admin' + assert cache_env.statements == [] + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + for name, value in expected.items(): + assert response.json()['user'][name] == value + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + + +@pytest.mark.asyncio +async def test_timezone_service_commit_refreshes_cached_preference(cache_env: SimpleNamespace) -> None: # noqa: F811 + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + async with cache_env.sessions() as db: + result = await UserService.update_user_timezone_services(db, USER_ID, 'reader', 'Europe/London') + assert result.is_success + + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert response.json()['user']['timeZone'] == 'Europe/London' + assert len(cache_env.statements) == REGULAR_QUERY_COUNT * 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('mutation', ['bulk', 'orm']) +async def test_password_changes_refresh_policy_without_caching_hash( + cache_env: SimpleNamespace, # noqa: F811 + mutation: str, +) -> None: + await cache_env.redis.set('sys_config:sys.account.initPasswordModify', '1') + first = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert first.json()['isDefaultModifyPwd'] + changed_at = TimezoneUtil.utc_now() + async with cache_env.sessions.begin() as db: + if mutation == 'bulk': + await UserDao.edit_user_dao( + db, {'user_id': USER_ID, 'password': 'changed-password-hash', 'pwd_update_date': changed_at} + ) + else: + # 认证中心改密使用ORM对象赋值,提交时同样需要失效用户快照。 + user = await db.get(SysUser, USER_ID) + user.password = 'changed-password-hash' + user.pwd_update_date = changed_at + cache_env.statements.clear() + + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + assert not response.json()['isDefaultModifyPwd'] + assert response.json()['user']['pwdUpdateDate'] is not None + assert response.json()['user']['password'] is None + key = CurrentUserCacheUtil._keys(cache_env.namespace, USER_ID)[2] + assert 'changed-password-hash' not in await cache_env.redis.hget(key, 'payload') + assert len(cache_env.statements) == REGULAR_QUERY_COUNT + + +@pytest.mark.asyncio +async def test_user_role_assignment_and_revocation_refresh_admin_permission( + cache_env: SimpleNamespace, # noqa: F811 +) -> None: + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assignment = UserRoleModel(userId=USER_ID, roleId=ADMIN_ID) + async with cache_env.sessions.begin() as db: + await UserDao.add_user_role_dao(db, assignment) + + elevated = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert elevated.json()['permissions'] == ['*:*:*'] + assert set(elevated.json()['roles']) == {'admin', 'reader'} + + async with cache_env.sessions.begin() as db: + await UserDao.delete_user_role_by_user_and_role_dao(db, assignment) + revoked = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert revoked.json()['permissions'] == ['demo:read'] + assert revoked.json()['roles'] == ['reader'] + + +@pytest.mark.asyncio +async def test_user_post_removal_and_assignment_refresh_cached_post_ids(cache_env: SimpleNamespace) -> None: # noqa: F811 + await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assignment = UserPostModel(userId=USER_ID, postId=USER_ID) + async with cache_env.sessions.begin() as db: + await UserDao.delete_user_post_dao(db, assignment) + + removed = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert removed.json()['user']['postIds'] == '' + + async with cache_env.sessions.begin() as db: + await UserDao.add_user_post_dao(db, assignment) + added = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert added.json()['user']['postIds'] == str(USER_ID) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('mutation', ['edit', 'disable', 'uninstall']) +async def test_plugin_menu_mutations_revoke_cached_permission( + cache_env: SimpleNamespace, # noqa: F811 + mutation: str, +) -> None: + await cache_env.client.get('/permitted', headers=cache_env.headers[USER_ID]) + async with cache_env.sessions.begin() as db: + if mutation == 'edit': + await PluginDao.update_sys_menu(db, {'menu_id': USER_ID, 'perms': 'demo:new'}) + elif mutation == 'disable': + await PluginDao.update_sys_menu_status_by_ids(db, [USER_ID], '1') + else: + await PluginDao.delete_sys_menus_by_ids(db, [USER_ID]) + + response = await cache_env.client.get('/permitted', headers=cache_env.headers[USER_ID]) + + assert response.json()['code'] == HttpStatusConstant.FORBIDDEN + assert len(cache_env.statements) == REGULAR_QUERY_COUNT * 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('alias_type', ['orm', 'core']) +@pytest.mark.parametrize('target_name', ['user', 'menu']) +@pytest.mark.parametrize('mutation', ['update', 'delete']) +async def test_aliased_mutations_refresh_cached_user_and_permissions( + cache_env: SimpleNamespace, # noqa: F811 + alias_type: str, + target_name: str, + mutation: str, +) -> None: + first = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + assert first.json()['permissions'] == ['demo:read'] + model = SysUser if target_name == 'user' else SysMenu + target = aliased(model, name='cache_target') if alias_type == 'orm' else model.__table__.alias('cache_target') + columns = target if alias_type == 'orm' else target.c + target_id = columns.user_id if target_name == 'user' else columns.menu_id + if mutation == 'update': + values = {'nick_name': 'Alias updated reader'} if target_name == 'user' else {'perms': 'demo:updated'} + statement = update(target).where(target_id == USER_ID).values(**values) + else: + statement = delete(target).where(target_id == USER_ID) + async with cache_env.sessions.begin() as db: + await db.execute(statement.execution_options(synchronize_session=False)) + cache_env.statements.clear() + + response = await cache_env.client.get('/profile', headers=cache_env.headers[USER_ID]) + + if target_name == 'user': + if mutation == 'update': + assert response.json()['user']['nickName'] == 'Alias updated reader' + else: + assert response.json()['code'] == HttpStatusConstant.UNAUTHORIZED + else: + assert response.json()['permissions'] == (['demo:updated'] if mutation == 'update' else []) + denied = await cache_env.client.get('/permitted', headers=cache_env.headers[USER_ID]) + assert denied.json()['code'] == HttpStatusConstant.FORBIDDEN + assert cache_env.statements + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings('ignore:UPDATE statement has a cartesian product:sqlalchemy.exc.SAWarning') +async def test_alias_update_with_original_table_condition_refreshes_all_affected_users( + cache_env: SimpleNamespace, # noqa: F811 +) -> None: + for user_id in (ADMIN_ID, USER_ID): + response = await cache_env.client.get('/profile', headers=cache_env.headers[user_id]) + assert response.json()['user']['userId'] == user_id + target = SysUser.__table__.alias('cache_target') + async with cache_env.sessions.begin() as db: + # 条件引用原表,匹配管理员时会更新别名目标中的全部用户。 + await db.execute( + update(target).where(SysUser.user_id == ADMIN_ID).values(nick_name='All alias targets updated') + ) + cache_env.statements.clear() + + for user_id in (ADMIN_ID, USER_ID): + response = await cache_env.client.get('/profile', headers=cache_env.headers[user_id]) + assert response.json()['user']['nickName'] == 'All alias targets updated' + assert cache_env.statements diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_resolution.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_resolution.py index f29790e6d..e1bac076d 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_resolution.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_current_user_resolution.py @@ -66,6 +66,7 @@ async def set(self, key: str, value: str, *, ex: int) -> None: @pytest_asyncio.fixture async def resolution_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[SimpleNamespace]: monkeypatch.setattr(AppConfig, 'app_same_time_login', True) + monkeypatch.setattr(AppConfig, 'app_current_user_cache_enabled', False) redis = AuthenticationRedis() engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "user-resolution.db"}') sessions = async_sessionmaker(engine, expire_on_commit=False, autoflush=False) diff --git a/ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py b/ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py index dad9a7164..1fcaf7956 100644 --- a/ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py +++ b/ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py @@ -4,6 +4,7 @@ import pytest +from config.database import DataSourceRegistry from config.env import AppConfig from plugins.core.runtime.application import ( get_plugin_application_runtime as get_runtime_plugin_application_runtime, @@ -153,7 +154,7 @@ async def test_lifespan_only_application_leader_outputs_banner_and_addresses() - patch('server.logger', fake_logger), ): async with lifespan(app): - pass + assert DataSourceRegistry._cache_redis is redis start_renewal.assert_called_once_with(redis) initialize_runtime.assert_awaited_once_with(app, application_leader=True) @@ -170,6 +171,7 @@ async def test_lifespan_only_application_leader_outputs_banner_and_addresses() - ) fake_logger.complete.assert_awaited_once_with() shutdown_runtime.assert_awaited_once_with(app) + assert DataSourceRegistry._cache_redis is None @pytest.mark.asyncio @@ -303,6 +305,7 @@ async def record_scheduler_shutdown() -> None: events.append('scheduler') async def record_redis_shutdown(_app: object) -> None: + assert DataSourceRegistry._cache_redis is None events.append('redis') async def record_database_shutdown() -> None: @@ -312,6 +315,7 @@ async def record_log_complete() -> None: events.append('logs') plugin_runtime.shutdown = AsyncMock(side_effect=record_plugin_shutdown) + DataSourceRegistry.set_cache_redis(app.state.redis) with ( patch('server.get_plugin_application_runtime', return_value=plugin_runtime), patch( From 3bcc5bb56cc88fb596a7d92a80d877dd23d97ddd Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Thu, 29 Oct 2026 11:12:01 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=E8=AF=B4=E6=98=8E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=BC=93=E5=AD=98=E4=B8=80=E8=87=B4=E6=80=A7=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-fastapi-backend/docs/cli_usage.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ruoyi-fastapi-backend/docs/cli_usage.md b/ruoyi-fastapi-backend/docs/cli_usage.md index 3c54d1938..ff59eb8b2 100644 --- a/ruoyi-fastapi-backend/docs/cli_usage.md +++ b/ruoyi-fastapi-backend/docs/cli_usage.md @@ -425,6 +425,20 @@ ruoyi cache clear --env=dev --all --yes ruoyi cache warmup --env=dev --yes ``` +当前用户资料和权限快照使用 `current_user_cache` 前缀,可单独清理: + +```bash +ruoyi cache clear --env=dev --cache-name=current_user_cache --yes +``` + +当前用户缓存默认开启,可通过 `APP_CURRENT_USER_CACHE_ENABLED=false` 关闭。`APP_CURRENT_USER_CACHE_TTL_SECONDS` 默认值为 `5` 秒,允许范围为 `1` 至 `60` 秒。有效期从数据库查询开始前计算,缓存命中和令牌续期不会延长;已有事务内的鉴权继续查询原会话,不缓存事务中的数据。 + +通过应用受监控的异步数据库会话写入用户资料、角色、菜单、部门、岗位及授权关系,会在事务提交后主动失效;通过该会话执行的原生 SQL 写入也在覆盖范围内,回滚不会触发失效。数据库提交与 Redis 失效之间仍存在并发窗口;提交后进程退出、取消或 Redis 故障也可能使旧快照继续用于新请求。默认最长复用至查询开始后的 `5` 秒,调整有效期配置后按对应期限限制;这不会终止已经取得旧权限的在途请求,也不提供零延迟的权限撤销保证。 + +使用外部工具直接改库、同步会话、原生数据库连接或自行创建的未受监控会话修改用户及权限后,应执行上述清理命令,或者等待快照到期。绕过会话事件的底层操作,以及数据库触发器、存储函数等间接修改,也需要主动清理或等待过期。`--env=dev` 应替换为实际部署环境;生产环境需按危险命令规则加上 `--allow-prod`。 + +缓存失效失败不会将已成功提交的事务变为普通异常;取消仍会向调用方传播,不能据此判断事务已经回滚。Redis 登录会话校验仍须成功,数据库依赖的故障恢复健康检查也保留原行为,缓存不提供 Redis 或数据库故障时的离线鉴权。缓存失效不等于撤销登录令牌,重置密码仍遵循原有会话策略。 + ### 5.5 `job` 用于定时任务查询、执行和同步。