mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 20:55:14 +00:00
refactor: 整合仪表盘功能到监控模块,清理冗余代码
- 移除原监控仪表盘独立模块,将相关功能合并到在线监控模块 - 重构租户配置字段名,统一使用logo_url和name替代tenant_logo/tenant_name - 优化搜索工具函数,移除重复导入 - 调整参数配置模型字段长度限制,移除config_value的max_length约束 - 清理冗余的常量定义和导入语句 - 修复批量状态设置接口的redis依赖注入 - 增强OAuth登录安全性,添加租户默认归属和state一次性消费 - 优化资源目录缓存逻辑,减少重复计算 - 新增API Token模块基础框架 - 完善用户token版本管理,支持主动失效JWT - 调整AI模型配置缓存过期时间 - 修复菜单类型字段索引,提升查询性能 - 简化前端刷新token调用逻辑 - 新增滑块验证完成接口和忘记密码验证码校验 - 调整系统配置默认值,添加操作日志保留天数和接口白名单配置 - 限制Mock支付回调仅在开发环境可用 - 重构websocket认证方式,支持更安全的subprotocol传参
This commit is contained in:
@@ -37,6 +37,8 @@ from .schema import (
|
||||
LoginWithTenantsSchema,
|
||||
SelectTenantOutSchema,
|
||||
SelectTenantSchema,
|
||||
SliderCompleteOutSchema,
|
||||
SliderCompleteSchema,
|
||||
TenantLookupOutSchema,
|
||||
TenantOptionSchema,
|
||||
TenantRegisterOutSchema,
|
||||
@@ -74,6 +76,25 @@ async def lookup_tenant_by_domain_controller(
|
||||
return SuccessResponse(data=data, msg="查询成功")
|
||||
|
||||
|
||||
@AuthRouter.get("/tenant-options", summary="获取所有租户选项(登录页下拉选择)", response_model=ResponseSchema[list[TenantOptionSchema]])
|
||||
async def get_tenant_options_controller(
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
"""获取所有活跃租户下拉选项"""
|
||||
data = await TenantLookupService.list_options(db=db)
|
||||
return SuccessResponse(data=data, msg="查询成功")
|
||||
|
||||
|
||||
@AuthRouter.get("/tenant-search", summary="搜索租户(按编码/名称模糊匹配)", response_model=ResponseSchema[list[TenantOptionSchema]])
|
||||
async def search_tenant_controller(
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
q: Annotated[str, Query(description="搜索关键字")],
|
||||
) -> JSONResponse:
|
||||
"""模糊搜索租户"""
|
||||
data = await TenantLookupService.search(db=db, q=q)
|
||||
return SuccessResponse(data=data, msg="查询成功")
|
||||
|
||||
|
||||
@AuthRouter.post("/login", summary="登录", response_model=LoginWithTenantsSchema)
|
||||
async def login_for_access_token_controller(
|
||||
request: Request,
|
||||
@@ -109,6 +130,15 @@ async def get_captcha_for_login_controller(
|
||||
return SuccessResponse(data=captcha, msg="获取验证码成功")
|
||||
|
||||
|
||||
@AuthRouter.post("/captcha/slider/complete", summary="滑块验证完成", response_model=ResponseSchema[SliderCompleteOutSchema])
|
||||
async def slider_complete_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
body: SliderCompleteSchema,
|
||||
) -> JSONResponse:
|
||||
result = await CaptchaService.slider_complete(redis=redis, captcha_key=body.captcha_key)
|
||||
return SuccessResponse(data=result, msg="滑块验证成功")
|
||||
|
||||
|
||||
@AuthRouter.post("/logout", summary="退出登录", response_model=ResponseSchema[None], dependencies=[Depends(get_current_user)])
|
||||
async def logout_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
|
||||
@@ -316,6 +316,7 @@ async def ensure_oauth_user(
|
||||
username=username,
|
||||
password=secrets.token_urlsafe(24),
|
||||
name=(display_name or username)[:32],
|
||||
tenant_id=1, # 系统租户:OAuth 用户未指定业务租户,统一落到 system tenant
|
||||
role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS),
|
||||
)
|
||||
try:
|
||||
@@ -344,6 +345,8 @@ async def complete_oauth_login(
|
||||
) -> tuple[JWTOutSchema, str]:
|
||||
rc = RedisCURD(redis)
|
||||
raw = await rc.get(f"{STATE_PREFIX}{state}")
|
||||
# 安全加固:state 一次性消费(read-then-delete)— 防止重放 / 跨上下文劫持
|
||||
await rc.delete(f"{STATE_PREFIX}{state}")
|
||||
if not raw:
|
||||
raise CustomException(msg="登录状态已失效,请重试")
|
||||
if isinstance(raw, bytes):
|
||||
|
||||
@@ -2,23 +2,8 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from app.core.base_schema import AuthSchema, CoreUserSchema, JWTOutSchema
|
||||
from app.core.base_schema import JWTOutSchema
|
||||
|
||||
__all__ = [
|
||||
"AuthSchema",
|
||||
"CoreUserSchema",
|
||||
"CaptchaOutSchema",
|
||||
"TenantOptionSchema",
|
||||
"SelectTenantSchema",
|
||||
"SelectTenantOutSchema",
|
||||
"LoginWithTenantsSchema",
|
||||
"TenantRegisterSchema",
|
||||
"TenantRegisterOutSchema",
|
||||
"EnterPlatformOutSchema",
|
||||
"TenantLookupOutSchema",
|
||||
"ImpersonateSchema",
|
||||
"ImpersonateOutSchema",
|
||||
]
|
||||
|
||||
class CaptchaOutSchema(BaseModel):
|
||||
"""验证码响应模型"""
|
||||
@@ -27,7 +12,7 @@ class CaptchaOutSchema(BaseModel):
|
||||
|
||||
enable: bool = Field(default=True, description="是否启用验证码")
|
||||
key: str = Field(..., min_length=1, description="验证码唯一标识")
|
||||
img_base: str = Field(..., min_length=1, description="Base64编码的验证码图片")
|
||||
img_base: str = Field(default="", description="Base64编码的验证码图片(滑块模式为空字符串)")
|
||||
|
||||
|
||||
class TenantOptionSchema(BaseModel):
|
||||
@@ -123,3 +108,16 @@ class ImpersonateOutSchema(BaseModel):
|
||||
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
|
||||
tenant_id: int = Field(..., description="目标租户ID")
|
||||
tenant_name: str = Field(..., description="目标租户名称")
|
||||
|
||||
|
||||
class SliderCompleteSchema(BaseModel):
|
||||
"""滑块验证完成请求"""
|
||||
|
||||
captcha_key: str = Field(..., min_length=1, description="验证码唯一标识")
|
||||
|
||||
|
||||
class SliderCompleteOutSchema(BaseModel):
|
||||
"""滑块验证完成响应"""
|
||||
|
||||
captcha_key: str = Field(..., description="验证码唯一标识")
|
||||
verified: bool = Field(default=True, description="是否验证通过")
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta
|
||||
from typing import NewType
|
||||
from typing import Any, NewType
|
||||
|
||||
import ua_parser
|
||||
from fastapi import BackgroundTasks, Request
|
||||
from redis.asyncio.client import Redis
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
|
||||
from app.api.v1.module_system.log.crud import LoginLogCRUD
|
||||
from app.api.v1.module_system.log.model import LoginLogModel
|
||||
from app.api.v1.module_system.log.schema import LoginLogCreateSchema
|
||||
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
|
||||
from app.api.v1.module_system.user.crud import UserCRUD
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
from app.api.v1.module_system.user.model import UserModel, UserRolesModel
|
||||
from app.api.v1.module_system.user.schema import UserOutSchema
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.config.setting import settings
|
||||
from app.core.base_schema import AuthSchema, JWTOutSchema, JWTPayloadSchema
|
||||
from app.core.database import async_db_session
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.core.redis_crud import RedisCURD
|
||||
from app.core.request_context import (
|
||||
RequestContext,
|
||||
clear_current_tenant,
|
||||
set_current_tenant,
|
||||
)
|
||||
from app.core.security import (
|
||||
CustomOAuth2PasswordRequestForm,
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
)
|
||||
from app.utils.captcha_util import CaptchaUtil
|
||||
from app.utils.common_util import get_random_character
|
||||
from app.utils.password_util import PwdUtil
|
||||
from app.utils.ip_local_util import IpLocalUtil, get_client_ip
|
||||
from app.utils.password_util import PwdUtil
|
||||
|
||||
from .schema import (
|
||||
CaptchaOutSchema,
|
||||
@@ -51,10 +65,6 @@ async def _write_login_log(
|
||||
msg: str | None = None,
|
||||
) -> int | None:
|
||||
"""写入登录日志;返回日志 ID(用于后台补全归属地)。"""
|
||||
from app.api.v1.module_system.log.crud import LoginLogCRUD
|
||||
from app.api.v1.module_system.log.schema import LoginLogCreateSchema
|
||||
from app.core.database import async_db_session
|
||||
|
||||
try:
|
||||
async with async_db_session() as session, session.begin():
|
||||
_auth = AuthSchema(check_data_scope=False)
|
||||
@@ -83,11 +93,6 @@ async def _async_fill_login_location(redis, login_log_id: int, ip: str | None) -
|
||||
logger.info(f"异步解析IP归属地结果: ip={ip}, log_id={login_log_id}, location={location}")
|
||||
if location == "归属地查询中" or not location:
|
||||
return
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from app.api.v1.module_system.log.model import LoginLogModel
|
||||
from app.core.database import async_db_session
|
||||
|
||||
async with async_db_session() as session, session.begin():
|
||||
await session.execute(sa_update(LoginLogModel).where(LoginLogModel.id == login_log_id).values(login_location=location))
|
||||
logger.info(f"登录日志归属地已更新: log_id={login_log_id}, location={location}")
|
||||
@@ -102,6 +107,47 @@ class LoginService:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _collect_permissions(
|
||||
user: UserModel,
|
||||
) -> tuple[list[str], dict[str, int], list[int], list[int], list[int], list[int]]:
|
||||
"""收集用户角色下的权限、菜单、数据范围及角色 ID
|
||||
|
||||
遍历用户角色,聚合所有关联菜单的 permission、menu_id、
|
||||
data_scope、自定义部门 ID 和角色 ID 列表。
|
||||
|
||||
参数:
|
||||
- user (UserModel): 用户对象
|
||||
|
||||
返回:
|
||||
- tuple[list[str], dict[str, int], list[int], list[int], list[int], list[int]]:
|
||||
(permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids)
|
||||
"""
|
||||
permissions: list[str] = []
|
||||
permissions_with_menu: dict[str, int] = {}
|
||||
menu_ids: list[int] = []
|
||||
data_scopes: list[int] = []
|
||||
custom_dept_ids: list[int] = []
|
||||
role_ids: list[int] = []
|
||||
if not user.is_superuser and hasattr(user, "roles"):
|
||||
for role in user.roles:
|
||||
if role and role.status == 0:
|
||||
role_ids.append(role.id)
|
||||
if hasattr(role, "menus"):
|
||||
for menu in role.menus:
|
||||
if menu and menu.status == 0:
|
||||
menu_ids.append(menu.id)
|
||||
if menu.permission:
|
||||
permissions.append(menu.permission)
|
||||
permissions_with_menu[menu.permission] = menu.id
|
||||
if hasattr(role, "data_scope"):
|
||||
data_scopes.append(role.data_scope)
|
||||
if hasattr(role, "depts") and role.depts:
|
||||
for dept in role.depts:
|
||||
if dept:
|
||||
custom_dept_ids.append(dept.id)
|
||||
return permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids
|
||||
|
||||
@classmethod
|
||||
async def authenticate_user(
|
||||
cls,
|
||||
@@ -123,12 +169,12 @@ class LoginService:
|
||||
request_from_docs = referer.endswith(("docs", "redoc"))
|
||||
|
||||
if settings.CAPTCHA_ENABLE and not request_from_docs:
|
||||
if not login_form.captcha_key or not login_form.captcha:
|
||||
if not login_form.captcha_key:
|
||||
raise CustomException(msg="验证码不能为空")
|
||||
# 滑块模式:slider_complete 已验证身份,此处仅校验状态
|
||||
await CaptchaService.check_captcha(
|
||||
redis=redis,
|
||||
key=login_form.captcha_key,
|
||||
captcha=login_form.captcha,
|
||||
)
|
||||
|
||||
auth = AuthSchema(check_data_scope=False)
|
||||
@@ -169,10 +215,6 @@ class LoginService:
|
||||
)
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
|
||||
tenant_stmt = select(TenantModel).where(TenantModel.id == user.tenant_id, TenantModel.status == 0, TenantModel.is_deleted.is_(False)).limit(1)
|
||||
tenant_result = await db.execute(tenant_stmt)
|
||||
if not tenant_result.scalar_one_or_none():
|
||||
@@ -234,56 +276,42 @@ class LoginService:
|
||||
user_info=user_info,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create_token(cls, request: Request, redis: Redis, user: UserModel, login_type: str) -> JWTOutSchema:
|
||||
"""创建访问令牌和刷新令牌"""
|
||||
session_id = str(uuid.uuid4())
|
||||
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
|
||||
request_ip = get_client_ip(request)
|
||||
@staticmethod
|
||||
def _build_session_dict(
|
||||
user: UserModel,
|
||||
session_id: str,
|
||||
permissions: list[str],
|
||||
permissions_with_menu: dict[str, int],
|
||||
menu_ids: list[int],
|
||||
data_scopes: list[int],
|
||||
custom_dept_ids: list[int],
|
||||
role_ids: list[int],
|
||||
request_ip: str,
|
||||
login_location: str | None,
|
||||
ua_result: Any,
|
||||
login_type: str,
|
||||
) -> dict:
|
||||
"""构建会话信息字典
|
||||
|
||||
login_location = await IpLocalUtil.resolve_location_for_log(redis, request_ip)
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from app.core.request_context import RequestContext
|
||||
|
||||
base_ctx = getattr(request.state, "ctx", None) or RequestContext()
|
||||
request.state.ctx = replace(
|
||||
base_ctx,
|
||||
session_id=session_id,
|
||||
user_username=user.username,
|
||||
login_location=login_location,
|
||||
)
|
||||
|
||||
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
|
||||
now = datetime.now()
|
||||
参数:
|
||||
- user (UserModel): 用户对象
|
||||
- session_id (str): 会话ID
|
||||
- permissions (list[str]): 权限标识列表
|
||||
- permissions_with_menu (dict[str, int]): 权限与菜单ID映射
|
||||
- menu_ids (list[int]): 菜单ID列表
|
||||
- data_scopes (list[int]): 数据范围列表
|
||||
- custom_dept_ids (list[int]): 自定义部门ID列表
|
||||
- role_ids (list[int]): 角色ID列表
|
||||
- request_ip (str): 请求IP
|
||||
- login_location (str): 登录地点
|
||||
- ua_result: User-Agent 解析结果
|
||||
- login_type (str): 登录类型
|
||||
|
||||
返回:
|
||||
- dict: 会话信息字典
|
||||
"""
|
||||
tenant_status = getattr(user.tenant, "status", 0) if hasattr(user, "tenant") and user.tenant else 0
|
||||
|
||||
permissions = []
|
||||
permissions_with_menu = {}
|
||||
menu_ids = []
|
||||
data_scopes = []
|
||||
custom_dept_ids = []
|
||||
if not user.is_superuser and hasattr(user, "roles"):
|
||||
for role in user.roles:
|
||||
if role and role.status == 0 and hasattr(role, "menus"):
|
||||
for menu in role.menus:
|
||||
if menu and menu.status == 0:
|
||||
menu_ids.append(menu.id)
|
||||
if menu.permission:
|
||||
permissions.append(menu.permission)
|
||||
permissions_with_menu[menu.permission] = menu.id
|
||||
if hasattr(role, "data_scope"):
|
||||
data_scopes.append(role.data_scope)
|
||||
if hasattr(role, "depts") and role.depts:
|
||||
for dept in role.depts:
|
||||
if dept:
|
||||
custom_dept_ids.append(dept.id)
|
||||
|
||||
session_dict = {
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"user_id": user.id,
|
||||
"tenant_id": user.tenant_id if not user.is_superuser else 0,
|
||||
@@ -302,6 +330,7 @@ class LoginService:
|
||||
"menu_ids": menu_ids,
|
||||
"data_scopes": data_scopes,
|
||||
"custom_dept_ids": custom_dept_ids,
|
||||
"role_ids": role_ids,
|
||||
"ipaddr": request_ip,
|
||||
"login_location": login_location,
|
||||
"os": ua_result.os.family if ua_result.os else "Unknown",
|
||||
@@ -309,6 +338,45 @@ class LoginService:
|
||||
"login_time": user.last_login,
|
||||
"login_type": login_type,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def create_token(cls, request: Request, redis: Redis, user: UserModel, login_type: str) -> JWTOutSchema:
|
||||
"""创建访问令牌和刷新令牌"""
|
||||
session_id = str(uuid.uuid4())
|
||||
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
|
||||
request_ip = get_client_ip(request)
|
||||
|
||||
login_location = await IpLocalUtil.resolve_location_for_log(redis, request_ip)
|
||||
|
||||
base_ctx = getattr(request.state, "ctx", None) or RequestContext()
|
||||
request.state.ctx = replace(
|
||||
base_ctx,
|
||||
session_id=session_id,
|
||||
user_username=user.username,
|
||||
login_location=login_location,
|
||||
)
|
||||
|
||||
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids = LoginService._collect_permissions(user)
|
||||
|
||||
session_dict = LoginService._build_session_dict(
|
||||
user=user,
|
||||
session_id=session_id,
|
||||
permissions=permissions,
|
||||
permissions_with_menu=permissions_with_menu,
|
||||
menu_ids=menu_ids,
|
||||
data_scopes=data_scopes,
|
||||
custom_dept_ids=custom_dept_ids,
|
||||
role_ids=role_ids,
|
||||
request_ip=request_ip,
|
||||
login_location=login_location,
|
||||
ua_result=ua_result,
|
||||
login_type=login_type,
|
||||
)
|
||||
session_info = json.dumps(session_dict, default=str)
|
||||
|
||||
# 会话信息存 Redis(完整 JSON),JWT sub 仅含 session_id
|
||||
@@ -450,8 +518,6 @@ class LoginService:
|
||||
"""获取用户关联的租户列表"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
|
||||
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未认证用户")
|
||||
@@ -487,10 +553,6 @@ class LoginService:
|
||||
tenant_id: int,
|
||||
) -> SelectTenantOutSchema:
|
||||
"""选择租户:验证用户归属并签发含租户上下文的新 JWT Token"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
|
||||
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未认证用户")
|
||||
@@ -514,57 +576,10 @@ class LoginService:
|
||||
if not tenant:
|
||||
raise CustomException(msg="租户不存在或已被禁用")
|
||||
|
||||
ctx = getattr(request.state, "ctx", None)
|
||||
session_id = ctx.session_id if ctx else None
|
||||
session_info = ctx.session_info if ctx else None
|
||||
|
||||
if not session_id or not session_info:
|
||||
raise CustomException(msg="会话已失效")
|
||||
|
||||
# 更新会话中的租户 ID 并写回 Redis
|
||||
session_info["tenant_id"] = tenant_id
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
from app.core.redis_crud import RedisCURD
|
||||
from app.core.security import create_access_token
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}",
|
||||
value=json.dumps(session_info) if isinstance(session_info, dict) else session_info,
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
new_access_token, _new_refresh_token, access_expires = await self._rebuild_tokens(
|
||||
request, redis, {"tenant_id": tenant_id}
|
||||
)
|
||||
|
||||
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
now = datetime.now()
|
||||
|
||||
new_access_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=False,
|
||||
exp=now + access_expires,
|
||||
),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
|
||||
value=new_access_token,
|
||||
expire=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
new_refresh_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=True,
|
||||
exp=now + refresh_expires,
|
||||
),
|
||||
)
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
|
||||
value=new_refresh_token,
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
)
|
||||
|
||||
from app.core.request_context import set_current_tenant
|
||||
|
||||
set_current_tenant(tenant_id)
|
||||
|
||||
logger.info(f"用户 {user.username}(id={user.id}) 切换到租户 {tenant.name}(id={tenant_id})")
|
||||
@@ -575,16 +590,24 @@ class LoginService:
|
||||
expires_in=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
async def enter_platform(
|
||||
async def _rebuild_tokens(
|
||||
self,
|
||||
request: Request,
|
||||
redis: Redis,
|
||||
) -> EnterPlatformOutSchema:
|
||||
"""进入平台管理模式:清除会话中的 tenant_id,返回平台作用域 JWT"""
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未认证用户")
|
||||
session_updates: dict,
|
||||
) -> tuple[str, str, timedelta]:
|
||||
"""从请求上下文重建全套令牌(access + refresh + session)
|
||||
|
||||
提取会话信息,应用更新后写入 Redis,签发新 JWT。
|
||||
|
||||
参数:
|
||||
- request (Request): FastAPI 请求对象
|
||||
- redis (Redis): Redis 客户端
|
||||
- session_updates (dict): 需更新到 session_info 的键值对
|
||||
|
||||
返回:
|
||||
- tuple[str, str, timedelta]: (access_token, refresh_token, access_expires)
|
||||
"""
|
||||
ctx = getattr(request.state, "ctx", None)
|
||||
session_id = ctx.session_id if ctx else None
|
||||
session_info = ctx.session_info if ctx else None
|
||||
@@ -592,7 +615,7 @@ class LoginService:
|
||||
if not session_id or not session_info:
|
||||
raise CustomException(msg="会话已失效")
|
||||
|
||||
session_info["tenant_id"] = 0
|
||||
session_info.update(session_updates)
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
@@ -631,7 +654,21 @@ class LoginService:
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
)
|
||||
|
||||
from app.core.request_context import clear_current_tenant
|
||||
return new_access_token, new_refresh_token, access_expires
|
||||
|
||||
async def enter_platform(
|
||||
self,
|
||||
request: Request,
|
||||
redis: Redis,
|
||||
) -> EnterPlatformOutSchema:
|
||||
"""进入平台管理模式:清除会话中的 tenant_id,返回平台作用域 JWT"""
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未认证用户")
|
||||
|
||||
new_access_token, _new_refresh_token, access_expires = await self._rebuild_tokens(
|
||||
request, redis, {"tenant_id": 0}
|
||||
)
|
||||
|
||||
clear_current_tenant()
|
||||
|
||||
@@ -650,10 +687,6 @@ class LoginService:
|
||||
tenant_id: int,
|
||||
) -> ImpersonateOutSchema:
|
||||
"""平台管理员代签入:以指定租户身份登录(仅超级管理员可用)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
|
||||
user = self.auth.user
|
||||
if not user or not user.is_superuser:
|
||||
raise CustomException(msg="仅平台管理员可执行代签入")
|
||||
@@ -664,55 +697,10 @@ class LoginService:
|
||||
if not tenant:
|
||||
raise CustomException(msg="租户不存在")
|
||||
|
||||
ctx = getattr(request.state, "ctx", None)
|
||||
session_id = ctx.session_id if ctx else None
|
||||
session_info = ctx.session_info if ctx else None
|
||||
|
||||
if not session_id or not session_info:
|
||||
raise CustomException(msg="会话已失效")
|
||||
|
||||
session_info["tenant_id"] = tenant_id
|
||||
session_info["is_impersonate"] = True
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}",
|
||||
value=json.dumps(session_info) if isinstance(session_info, dict) else session_info,
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
new_access_token, new_refresh_token, access_expires = await self._rebuild_tokens(
|
||||
request, redis, {"tenant_id": tenant_id, "is_impersonate": True}
|
||||
)
|
||||
|
||||
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
now = datetime.now()
|
||||
|
||||
new_access_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=False,
|
||||
exp=now + access_expires,
|
||||
),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
|
||||
value=new_access_token,
|
||||
expire=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
new_refresh_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=True,
|
||||
exp=now + refresh_expires,
|
||||
),
|
||||
)
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
|
||||
value=new_refresh_token,
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
)
|
||||
|
||||
from app.core.request_context import set_current_tenant
|
||||
|
||||
set_current_tenant(tenant_id)
|
||||
|
||||
logger.warning(f"平台管理员 {user.username}(id={user.id}) 代签入租户 {tenant.name}(id={tenant_id})")
|
||||
@@ -728,43 +716,68 @@ class LoginService:
|
||||
|
||||
|
||||
class CaptchaService:
|
||||
"""验证码服务"""
|
||||
"""验证码服务 — 滑块拖动模式"""
|
||||
|
||||
@staticmethod
|
||||
async def get_captcha(redis: Redis) -> CaptchaOutSchema:
|
||||
"""获取验证码"""
|
||||
"""获取验证码(滑块模式:仅生成 key,无需算术图片)"""
|
||||
if not settings.CAPTCHA_ENABLE:
|
||||
raise CustomException(msg="未开启验证码服务")
|
||||
|
||||
captcha_base64, captcha_value = CaptchaUtil.captcha_arithmetic()
|
||||
captcha_key = get_random_character()
|
||||
|
||||
redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{captcha_key}"
|
||||
# 存储滑块状态:pending(待验证)/ verified(已验证通过)
|
||||
await RedisCURD(redis).set(
|
||||
key=redis_key,
|
||||
value=captcha_value,
|
||||
value="pending",
|
||||
expire=settings.CAPTCHA_EXPIRE_SECONDS,
|
||||
)
|
||||
|
||||
return CaptchaOutSchema(
|
||||
enable=settings.CAPTCHA_ENABLE,
|
||||
key=CaptchaKey(captcha_key),
|
||||
img_base=CaptchaBase64(f"data:image/png;base64,{captcha_base64}"),
|
||||
img_base=CaptchaBase64(""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def check_captcha(redis: Redis, key: str, captcha: str) -> bool:
|
||||
"""校验验证码"""
|
||||
if not captcha:
|
||||
raise CustomException(msg="验证码不能为空")
|
||||
async def slider_complete(redis: Redis, captcha_key: str) -> dict:
|
||||
"""标记滑块验证完成"""
|
||||
if not captcha_key:
|
||||
raise CustomException(msg="验证码标识不能为空")
|
||||
|
||||
redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{captcha_key}"
|
||||
status = await RedisCURD(redis).get(redis_key)
|
||||
if not status:
|
||||
raise CustomException(msg="验证码已过期,请刷新")
|
||||
|
||||
if isinstance(status, bytes):
|
||||
status = status.decode()
|
||||
|
||||
if status == "verified":
|
||||
raise CustomException(msg="验证码已使用")
|
||||
|
||||
# 标记为已验证
|
||||
await RedisCURD(redis).set(
|
||||
key=redis_key,
|
||||
value="verified",
|
||||
expire=settings.CAPTCHA_EXPIRE_SECONDS,
|
||||
)
|
||||
|
||||
return {"captcha_key": captcha_key, "verified": True}
|
||||
|
||||
@staticmethod
|
||||
async def check_captcha(redis: Redis, key: str) -> bool:
|
||||
"""校验滑块验证码:检查 key 状态是否为 verified"""
|
||||
redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{key}"
|
||||
captcha_value = await RedisCURD(redis).get(redis_key)
|
||||
if not captcha_value:
|
||||
raise CustomException(msg="验证码已过期")
|
||||
status = await RedisCURD(redis).get(redis_key)
|
||||
if not status:
|
||||
raise CustomException(msg="验证码已过期,请刷新")
|
||||
|
||||
if captcha.lower() != captcha_value.lower():
|
||||
raise CustomException(msg="验证码错误")
|
||||
if isinstance(status, bytes):
|
||||
status = status.decode()
|
||||
|
||||
if status != "verified":
|
||||
raise CustomException(msg="请先完成滑块验证")
|
||||
|
||||
await RedisCURD(redis).delete(redis_key)
|
||||
return True
|
||||
@@ -785,14 +798,8 @@ class TenantRegisterService:
|
||||
tenant_name: str | None = None,
|
||||
) -> TenantRegisterOutSchema:
|
||||
"""租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配"""
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
|
||||
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
|
||||
from app.api.v1.module_system.user.model import UserModel, UserRolesModel
|
||||
|
||||
exists_stmt = (
|
||||
select(func.count())
|
||||
.select_from(UserModel)
|
||||
@@ -892,10 +899,6 @@ class TenantLookupService:
|
||||
|
||||
@staticmethod
|
||||
async def lookup_by_code(db: AsyncSession, code: str) -> dict:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
|
||||
stmt = select(TenantModel).where(
|
||||
TenantModel.code == code,
|
||||
TenantModel.is_deleted.is_(False),
|
||||
@@ -915,10 +918,6 @@ class TenantLookupService:
|
||||
|
||||
@staticmethod
|
||||
async def lookup_by_domain(db: AsyncSession, domain: str) -> dict:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
|
||||
stmt = select(TenantModel).where(
|
||||
TenantModel.domain == domain,
|
||||
TenantModel.is_deleted.is_(False),
|
||||
@@ -935,3 +934,37 @@ class TenantLookupService:
|
||||
"login_bg": result.login_bg,
|
||||
"version": result.version,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def list_options(db: AsyncSession) -> list[dict]:
|
||||
"""获取所有活跃租户选项(登录页下拉选择)"""
|
||||
stmt = (
|
||||
select(TenantModel)
|
||||
.where(TenantModel.is_deleted.is_(False), TenantModel.status == 0)
|
||||
.order_by(TenantModel.id)
|
||||
)
|
||||
results = (await db.execute(stmt)).scalars().all()
|
||||
return [
|
||||
{"id": r.id, "name": r.name, "code": r.code}
|
||||
for r in results
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def search(db: AsyncSession, q: str) -> list[dict]:
|
||||
"""模糊搜索租户(按编码或名称)"""
|
||||
pattern = f"%{q}%"
|
||||
stmt = (
|
||||
select(TenantModel)
|
||||
.where(
|
||||
TenantModel.is_deleted.is_(False),
|
||||
TenantModel.status == 0,
|
||||
(TenantModel.code.ilike(pattern) | TenantModel.name.ilike(pattern)),
|
||||
)
|
||||
.order_by(TenantModel.id)
|
||||
.limit(20)
|
||||
)
|
||||
results = (await db.execute(stmt)).scalars().all()
|
||||
return [
|
||||
{"id": r.id, "name": r.name, "code": r.code}
|
||||
for r in results
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user