checkpoint: 权限即时生效——角色/用户变更自动踢下线 + roleless 用户自读修复
- online/service: kick_user_sessions 扫描 USER_SESSION 匹配目标用户,删三键使登录时固化的权限快照即时失效 - role service+controller: 更新/删除/改状态/改权限后踢下线绑定该角色的用户 - user service+controller: 停用/改密等变更后踢下线(对应用户即时 401 重登) - permission: 无角色用户读自身记录时按 id 匹配放行(修复 409 真 bug)
This commit is contained in:
@@ -78,6 +78,52 @@ class OnlineService:
|
||||
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
|
||||
logger.info(f"强制下线用户会话: {session_id}")
|
||||
|
||||
@staticmethod
|
||||
async def kick_user_sessions(redis: Redis, user_ids: list[int]) -> int:
|
||||
"""强制下线指定用户的全部在线会话(权限变更/停用/改密后自动踢人)。
|
||||
|
||||
扫描 USER_SESSION:{session_id} 匹配 user_id,删除该会话的
|
||||
ACCESS_TOKEN / REFRESH_TOKEN / USER_SESSION 三键,使登录时固化的
|
||||
权限快照即时失效(用户下次请求 401,重新登录拿新权限)。
|
||||
返回踢下线会话数;redis 不可用时仅记日志,不影响业务主流程。
|
||||
"""
|
||||
if not user_ids:
|
||||
return 0
|
||||
target = {int(uid) for uid in user_ids}
|
||||
keys = await RedisCURD(redis).scan_keys(f"{RedisInitKeyConfig.USER_SESSION.key}:*")
|
||||
if not keys:
|
||||
return 0
|
||||
raws = await RedisCURD(redis).mget(keys)
|
||||
session_ids: list[str] = []
|
||||
for key, raw in zip(keys, raws, strict=False):
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
info = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
try:
|
||||
uid = int(info.get("user_id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if uid in target:
|
||||
key_str = key.decode() if isinstance(key, bytes) else key
|
||||
session_ids.append(key_str.split(":")[-1])
|
||||
if not session_ids:
|
||||
return 0
|
||||
delete_keys: list[str] = []
|
||||
for sid in session_ids:
|
||||
delete_keys.extend(
|
||||
[
|
||||
f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{sid}",
|
||||
f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{sid}",
|
||||
f"{RedisInitKeyConfig.USER_SESSION.key}:{sid}",
|
||||
]
|
||||
)
|
||||
await RedisCURD(redis).delete(*delete_keys)
|
||||
logger.info(f"权限变更自动踢下线: user_ids={sorted(target)} sessions={len(session_ids)}")
|
||||
return len(session_ids)
|
||||
|
||||
@staticmethod
|
||||
async def clear_online(redis: Redis) -> None:
|
||||
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*")
|
||||
|
||||
@@ -2,11 +2,12 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from redis.asyncio.client import Redis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
from app.core.dependencies import AuthPermission, db_getter, redis_getter
|
||||
from app.core.router_class import OperationLogRoute
|
||||
from app.utils.common_util import bytes2file_response
|
||||
|
||||
@@ -56,10 +57,11 @@ async def create_role_controller(
|
||||
async def update_role_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:update"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
id: Annotated[int, Path(description="角色ID", ge=1)],
|
||||
data: Annotated[RoleUpdateSchema, Body(description="角色修改参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await RoleService(auth, db).update(id=id, data=data)
|
||||
result_dict = await RoleService(auth, db).update(id=id, data=data, redis=redis)
|
||||
return SuccessResponse(data=result_dict, msg="修改角色成功")
|
||||
|
||||
|
||||
@@ -67,9 +69,10 @@ async def update_role_controller(
|
||||
async def delete_role_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:delete"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await RoleService(auth, db).delete(ids=ids)
|
||||
await RoleService(auth, db).delete(ids=ids, redis=redis)
|
||||
return SuccessResponse(msg="删除角色成功")
|
||||
|
||||
|
||||
@@ -77,9 +80,10 @@ async def delete_role_controller(
|
||||
async def batch_set_available_role_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:patch"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await RoleService(auth, db).set_available(data=data)
|
||||
await RoleService(auth, db).set_available(data=data, redis=redis)
|
||||
return SuccessResponse(msg="批量修改角色状态成功")
|
||||
|
||||
|
||||
@@ -87,9 +91,10 @@ async def batch_set_available_role_controller(
|
||||
async def set_role_permission_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:permission"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
data: Annotated[RolePermissionSettingSchema, Body(description="角色授权参数")],
|
||||
) -> JSONResponse:
|
||||
await RoleService(auth, db).set_permission(data=data)
|
||||
await RoleService(auth, db).set_permission(data=data, redis=redis)
|
||||
return SuccessResponse(msg="授权角色成功")
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.utils.common_util import search_to_dict
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
|
||||
@@ -110,12 +111,13 @@ class RoleService:
|
||||
new_role = await RoleCRUD(self.auth, self.db).create(data=data)
|
||||
return await self.detail(id=new_role.id)
|
||||
|
||||
async def update(self, id: int, data: RoleUpdateSchema) -> RoleOutSchema:
|
||||
async def update(self, id: int, data: RoleUpdateSchema, redis: Any | None = None) -> RoleOutSchema:
|
||||
"""更新角色
|
||||
|
||||
参数:
|
||||
- id (int): 角色ID
|
||||
- data (RoleUpdateSchema): 更新角色模型
|
||||
- redis (Any | None): Redis 客户端(非 None 时踢下线绑定该角色的用户)
|
||||
|
||||
返回:
|
||||
- RoleOutSchema: 更新后的角色响应模型
|
||||
@@ -128,13 +130,16 @@ class RoleService:
|
||||
if exist_code and exist_code.id != id:
|
||||
raise CustomException(msg="更新失败,角色编码已存在")
|
||||
await RoleCRUD(self.auth, self.db).update(id=id, data=data)
|
||||
if redis is not None:
|
||||
await self._kick_users(redis, [id])
|
||||
return await self.detail(id=id)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
async def delete(self, ids: list[int], redis: Any | None = None) -> None:
|
||||
"""删除角色
|
||||
|
||||
参数:
|
||||
- ids (list[int]): 角色ID列表
|
||||
- redis (Any | None): Redis 客户端(非 None 时踢下线绑定该角色的用户)
|
||||
|
||||
返回:
|
||||
- None
|
||||
@@ -148,12 +153,15 @@ class RoleService:
|
||||
raise CustomException(msg="删除失败,部分ID不存在")
|
||||
|
||||
await RoleCRUD(self.auth, self.db).delete(ids=ids)
|
||||
if redis is not None:
|
||||
await self._kick_users(redis, ids)
|
||||
|
||||
async def set_permission(self, data: RolePermissionSettingSchema) -> None:
|
||||
async def set_permission(self, data: RolePermissionSettingSchema, redis: Any | None = None) -> None:
|
||||
"""设置角色权限
|
||||
|
||||
参数:
|
||||
- data (RolePermissionSettingSchema): 角色权限设置模型
|
||||
- redis (Any | None): Redis 客户端(非 None 时踢下线绑定角色的用户)
|
||||
|
||||
返回:
|
||||
- None
|
||||
@@ -164,12 +172,15 @@ class RoleService:
|
||||
# 设置数据权限范围(自定义部门关联已废弃,直接清空)
|
||||
await RoleCRUD(self.auth, self.db).set(ids=data.role_ids, data_scope=data.data_scope)
|
||||
await RoleCRUD(self.auth, self.db).set_role_depts_crud(role_ids=data.role_ids, dept_ids=[])
|
||||
if redis is not None:
|
||||
await self._kick_users(redis, data.role_ids)
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
async def set_available(self, data: BatchSetAvailable, redis: Any | None = None) -> None:
|
||||
"""设置角色可用状态
|
||||
|
||||
参数:
|
||||
- data (BatchSetAvailable): 批量设置可用状态模型
|
||||
- redis (Any | None): Redis 客户端(非 None 时踢下线绑定该角色的用户)
|
||||
|
||||
返回:
|
||||
- None
|
||||
@@ -180,6 +191,23 @@ class RoleService:
|
||||
if rid not in role_map:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
await RoleCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
|
||||
if redis is not None:
|
||||
await self._kick_users(redis, data.ids)
|
||||
|
||||
async def _kick_users(self, redis: Any, role_ids: list[int]) -> int:
|
||||
"""踢下线绑定指定角色的全部用户的会话(角色权限/状态/删除后调用)。"""
|
||||
roles = await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", role_ids)}, preload=["users"])
|
||||
user_ids = sorted({u.id for r in roles for u in r.users})
|
||||
if not user_ids:
|
||||
return 0
|
||||
|
||||
from app.api.v1.module_monitor.online.service import OnlineService
|
||||
|
||||
try:
|
||||
return await OnlineService.kick_user_sessions(redis=redis, user_ids=user_ids)
|
||||
except Exception as e:
|
||||
logger.error(f"角色变更自动踢下线失败: role_ids={role_ids}, err={e!s}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def export_list(role_list: list[dict[str, Any]]) -> bytes:
|
||||
|
||||
@@ -56,9 +56,10 @@ async def update_current_user_info_controller(
|
||||
async def change_current_user_password_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
data: Annotated[UserChangePasswordSchema, Body(description="修改用户密码参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await UserService(auth, db).change_password(data=data)
|
||||
result_dict = await UserService(auth, db).change_password(data=data, redis=redis)
|
||||
return SuccessResponse(data=result_dict, msg="修改密码成功, 请重新登录")
|
||||
|
||||
|
||||
@@ -66,11 +67,12 @@ async def change_current_user_password_controller(
|
||||
async def reset_password_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:update"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
id: Annotated[int, Path(description="用户ID", ge=1)],
|
||||
data: Annotated[ResetPasswordSchema, Body(description="重置用户密码参数")],
|
||||
) -> JSONResponse:
|
||||
data.id = id
|
||||
result_dict = await UserService(auth, db).reset_password(data=data)
|
||||
result_dict = await UserService(auth, db).reset_password(data=data, redis=redis)
|
||||
return SuccessResponse(data=result_dict, msg="重置密码成功")
|
||||
|
||||
|
||||
@@ -159,10 +161,11 @@ async def create_user_controller(
|
||||
async def update_user_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:update"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
id: Annotated[int, Path(description="用户ID")],
|
||||
data: Annotated[UserUpdateSchema, Body(description="修改用户参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await UserService(auth, db).update(id=id, data=data)
|
||||
result_dict = await UserService(auth, db).update(id=id, data=data, redis=redis)
|
||||
return SuccessResponse(data=result_dict, msg="修改用户成功")
|
||||
|
||||
|
||||
@@ -170,9 +173,10 @@ async def update_user_controller(
|
||||
async def delete_user_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:delete"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await UserService(auth, db).delete(ids=ids)
|
||||
await UserService(auth, db).delete(ids=ids, redis=redis)
|
||||
return SuccessResponse(msg="删除用户成功")
|
||||
|
||||
|
||||
@@ -180,9 +184,10 @@ async def delete_user_controller(
|
||||
async def batch_set_available_user_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:patch"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await UserService(auth, db).set_available(data=data)
|
||||
await UserService(auth, db).set_available(data=data, redis=redis)
|
||||
return SuccessResponse(msg="批量修改用户状态成功")
|
||||
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ from app.api.v1.module_system.menu.crud import MenuCRUD
|
||||
from app.api.v1.module_system.menu.schema import MenuOutSchema, MenuTreeOutSchema
|
||||
from app.api.v1.module_system.position.crud import PositionCRUD
|
||||
from app.api.v1.module_system.role.crud import RoleCRUD
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.config.setting import settings
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
|
||||
from app.core.email import send_reset_code_email
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.core.redis_crud import RedisCURD
|
||||
from app.utils.common_util import search_to_dict, traversal_to_tree
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
@@ -133,7 +133,7 @@ class UserService:
|
||||
await UserCRUD(self.auth, self.db).set_user_positions(user_ids=[new_user.id], position_ids=data.position_ids)
|
||||
return await self.detail(id=new_user.id)
|
||||
|
||||
async def update(self, id: int, data: UserUpdateSchema) -> UserOutSchema:
|
||||
async def update(self, id: int, data: UserUpdateSchema, redis: Redis | None = None) -> UserOutSchema:
|
||||
if data.username:
|
||||
if exist_user := await UserCRUD(self.auth, self.db).get(username=data.username):
|
||||
if exist_user.id != id:
|
||||
@@ -174,9 +174,13 @@ class UserService:
|
||||
raise CustomException(msg="更新失败,部分岗位已被禁用")
|
||||
await UserCRUD(self.auth, self.db).set_user_positions(user_ids=[id], position_ids=data.position_ids)
|
||||
|
||||
# 角色/状态变更使登录时固化的权限快照失效,自动踢下线让其重登生效
|
||||
if redis is not None and (data.role_ids is not None or data.status is not None):
|
||||
await self._kick_users(redis, [id])
|
||||
|
||||
return await self.detail(id=id)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
async def delete(self, ids: list[int], redis: Redis | None = None) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
users = await UserCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)})
|
||||
@@ -199,6 +203,8 @@ class UserService:
|
||||
await UserCRUD(self.auth, self.db).set_user_roles(user_ids=ids, role_ids=[])
|
||||
await UserCRUD(self.auth, self.db).set_user_positions(user_ids=ids, position_ids=[])
|
||||
await UserCRUD(self.auth, self.db).delete(ids=ids)
|
||||
if redis is not None:
|
||||
await self._kick_users(redis, ids)
|
||||
|
||||
async def current_info(self, check_data_scope: bool = True) -> CurrentUserOutSchema:
|
||||
user_id = self.auth.user.id
|
||||
@@ -262,14 +268,16 @@ class UserService:
|
||||
await UserCRUD(self.auth, self.db).update(id=user_id, data=user_update_data)
|
||||
return await self.detail(id=user_id)
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
async def set_available(self, data: BatchSetAvailable, redis: Redis | None = None) -> None:
|
||||
users = await UserCRUD(self.auth, self.db).get_list(search={"id": ("in", data.ids)})
|
||||
for user in users:
|
||||
if user.is_superuser:
|
||||
raise CustomException(msg="超级管理员状态不能修改")
|
||||
await UserCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
|
||||
if redis is not None:
|
||||
await self._kick_users(redis, data.ids)
|
||||
|
||||
async def change_password(self, data: UserChangePasswordSchema) -> UserOutSchema:
|
||||
async def change_password(self, data: UserChangePasswordSchema, redis: Redis | None = None) -> UserOutSchema:
|
||||
user_id = self.auth.user.id
|
||||
if not user_id:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
@@ -280,17 +288,32 @@ class UserService:
|
||||
|
||||
new_password_hash = PwdUtil.hash_password(password=data.new_password)
|
||||
await UserCRUD(self.auth, self.db).change_password(id=user_id, password_hash=new_password_hash)
|
||||
if redis is not None:
|
||||
await self._kick_users(redis, [user_id])
|
||||
return await self.detail(id=user_id)
|
||||
|
||||
async def reset_password(self, data: ResetPasswordSchema) -> UserOutSchema:
|
||||
async def reset_password(self, data: ResetPasswordSchema, redis: Redis | None = None) -> UserOutSchema:
|
||||
user = await UserCRUD(self.auth, self.db).get_or_404(id=data.id)
|
||||
if user.is_superuser:
|
||||
raise CustomException(msg="超级管理员密码不能重置")
|
||||
|
||||
new_password_hash = PwdUtil.hash_password(password=data.password)
|
||||
await UserCRUD(self.auth, self.db).change_password(id=data.id, password_hash=new_password_hash)
|
||||
if redis is not None:
|
||||
await self._kick_users(redis, [data.id])
|
||||
return await self.detail(id=data.id)
|
||||
|
||||
@staticmethod
|
||||
async def _kick_users(redis: Redis, user_ids: list[int]) -> int:
|
||||
"""踢下线指定用户的全部会话(权限/状态/密码变更后调用)。"""
|
||||
from app.api.v1.module_monitor.online.service import OnlineService
|
||||
|
||||
try:
|
||||
return await OnlineService.kick_user_sessions(redis=redis, user_ids=user_ids)
|
||||
except Exception as e:
|
||||
logger.error(f"自动踢下线失败: user_ids={user_ids}, err={e!s}")
|
||||
return 0
|
||||
|
||||
async def forget_password(self, data: UserForgetPasswordSchema) -> str:
|
||||
"""忘记密码前置校验:返回绑定邮箱(脱敏),供前端确认;不存在或未绑定邮箱则异常。"""
|
||||
user = await UserCRUD(self.auth, self.db).get_or_404(username=data.username)
|
||||
@@ -330,6 +353,7 @@ class UserService:
|
||||
new_password_hash = PwdUtil.hash_password(password=data.new_password)
|
||||
await UserCRUD(self.auth, self.db).change_password(id=user.id, password_hash=new_password_hash)
|
||||
await RedisCURD(redis).delete(redis_key)
|
||||
await self._kick_users(redis, [user.id])
|
||||
|
||||
async def register(self, data: UserRegisterSchema) -> UserOutSchema:
|
||||
"""用户注册"""
|
||||
|
||||
@@ -48,6 +48,10 @@ class Permission:
|
||||
roles = result.scalars().all()
|
||||
|
||||
if not roles:
|
||||
if self.model.__name__ == "UserModel":
|
||||
id_attr = getattr(self.model, "id", None)
|
||||
if id_attr is not None and self.auth.user and self.auth.user.id:
|
||||
return id_attr == self.auth.user.id
|
||||
created_id_attr = getattr(self.model, "created_id", None)
|
||||
if created_id_attr is not None and self.auth.user and self.auth.user.id:
|
||||
return created_id_attr == self.auth.user.id
|
||||
|
||||
Reference in New Issue
Block a user