mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 13:05:18 +00:00
- 移除原监控仪表盘独立模块,将相关功能合并到在线监控模块 - 重构租户配置字段名,统一使用logo_url和name替代tenant_logo/tenant_name - 优化搜索工具函数,移除重复导入 - 调整参数配置模型字段长度限制,移除config_value的max_length约束 - 清理冗余的常量定义和导入语句 - 修复批量状态设置接口的redis依赖注入 - 增强OAuth登录安全性,添加租户默认归属和state一次性消费 - 优化资源目录缓存逻辑,减少重复计算 - 新增API Token模块基础框架 - 完善用户token版本管理,支持主动失效JWT - 调整AI模型配置缓存过期时间 - 修复菜单类型字段索引,提升查询性能 - 简化前端刷新token调用逻辑 - 新增滑块验证完成接口和忘记密码验证码校验 - 调整系统配置默认值,添加操作日志保留天数和接口白名单配置 - 限制Mock支付回调仅在开发环境可用 - 重构websocket认证方式,支持更安全的subprotocol传参
300 lines
13 KiB
Python
300 lines
13 KiB
Python
import json
|
||
import secrets
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query, Request, status
|
||
from fastapi.responses import JSONResponse, RedirectResponse
|
||
from fastapi_cache import FastAPICache
|
||
from fastapi_cache.decorator import cache
|
||
from redis.asyncio.client import Redis
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.common.response import ErrorResponse, RedirectContentResponse, ResponseSchema, SuccessResponse
|
||
from app.config.setting import settings
|
||
from app.core.base_schema import AuthSchema, JWTOutSchema
|
||
from app.core.dependencies import db_getter, get_current_user, redis_getter
|
||
from app.core.exceptions import CustomException
|
||
from app.core.logger import logger
|
||
from app.core.redis_crud import RedisCURD
|
||
from app.core.router_class import OperationLogRoute
|
||
from app.core.security import CustomOAuth2PasswordRequestForm
|
||
|
||
from .oauth_service import (
|
||
STATE_PREFIX,
|
||
OAuthProvider,
|
||
_callback_url,
|
||
build_authorize_url,
|
||
complete_oauth_login,
|
||
oauth_service_error_redirect,
|
||
oauth_service_frontend_redirect_from_token,
|
||
save_oauth_state,
|
||
)
|
||
from .schema import (
|
||
CaptchaOutSchema,
|
||
EnterPlatformOutSchema,
|
||
ImpersonateOutSchema,
|
||
ImpersonateSchema,
|
||
LoginWithTenantsSchema,
|
||
SelectTenantOutSchema,
|
||
SelectTenantSchema,
|
||
SliderCompleteOutSchema,
|
||
SliderCompleteSchema,
|
||
TenantLookupOutSchema,
|
||
TenantOptionSchema,
|
||
TenantRegisterOutSchema,
|
||
TenantRegisterSchema,
|
||
)
|
||
from .service import (
|
||
CaptchaService,
|
||
LoginService,
|
||
TenantLookupService,
|
||
TenantRegisterService,
|
||
)
|
||
|
||
AuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/auth", tags=["认证授权"])
|
||
|
||
_AUTH_TENANTS_NS = "auth_tenants"
|
||
|
||
|
||
@AuthRouter.get("/tenant/{code}", summary="通过编码查询租户", response_model=ResponseSchema[TenantLookupOutSchema])
|
||
async def lookup_tenant_controller(
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
code: Annotated[str, Path(description="租户编码")],
|
||
) -> JSONResponse:
|
||
"""根据租户编码查询租户信息(用于登录页自动加载租户品牌配置)"""
|
||
data = await TenantLookupService.lookup_by_code(db=db, code=code)
|
||
return SuccessResponse(data=data, msg="查询成功")
|
||
|
||
|
||
@AuthRouter.get("/tenant-by-domain", summary="通过域名查询租户", response_model=ResponseSchema[TenantLookupOutSchema])
|
||
async def lookup_tenant_by_domain_controller(
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
domain: Annotated[str, Query(description="域名(如 tenant.example.com)")],
|
||
) -> JSONResponse:
|
||
"""根据域名查询租户信息(用于登录页通过访问域名自动识别租户品牌)"""
|
||
data = await TenantLookupService.lookup_by_domain(db=db, domain=domain)
|
||
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,
|
||
background_tasks: BackgroundTasks,
|
||
redis: Annotated[Redis, Depends(redis_getter)],
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
login_form: Annotated[CustomOAuth2PasswordRequestForm, Depends()],
|
||
) -> JSONResponse | LoginWithTenantsSchema:
|
||
login_result = await LoginService.authenticate_user(request=request, redis=redis, login_form=login_form, db=db, background_tasks=background_tasks)
|
||
|
||
logger.info(f"用户{login_form.username}登录成功")
|
||
|
||
if settings.DOCS_URL in request.headers.get("referer", ""):
|
||
return login_result
|
||
return SuccessResponse(data=login_result, msg="登录成功")
|
||
|
||
|
||
@AuthRouter.post("/token/refresh", summary="刷新token", response_model=ResponseSchema[JWTOutSchema])
|
||
async def get_new_token_controller(
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
redis: Annotated[Redis, Depends(redis_getter)],
|
||
payload: Annotated[str, Body(description="刷新token参数")],
|
||
) -> JSONResponse:
|
||
new_token = await LoginService.refresh_token(db=db, redis=redis, refresh_token=payload)
|
||
return SuccessResponse(data=new_token, msg="刷新成功")
|
||
|
||
|
||
@AuthRouter.get("/captcha/get", summary="获取验证码", response_model=ResponseSchema[CaptchaOutSchema])
|
||
async def get_captcha_for_login_controller(
|
||
redis: Annotated[Redis, Depends(redis_getter)],
|
||
) -> JSONResponse:
|
||
captcha = await CaptchaService.get_captcha(redis=redis)
|
||
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)],
|
||
payload: Annotated[str, Body(description="退出登录参数")],
|
||
) -> JSONResponse:
|
||
if await LoginService.logout(redis=redis, token=payload):
|
||
logger.info("退出成功")
|
||
return SuccessResponse(msg="退出成功")
|
||
return ErrorResponse(msg="退出失败")
|
||
|
||
|
||
@AuthRouter.post("/select-tenant", summary="选择租户", response_model=ResponseSchema[SelectTenantOutSchema], dependencies=[Depends(get_current_user)])
|
||
async def select_tenant_controller(
|
||
request: Request,
|
||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||
redis: Annotated[Redis, Depends(redis_getter)],
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
data: Annotated[SelectTenantSchema, Body(description="租户选择参数")],
|
||
) -> JSONResponse:
|
||
result = await LoginService(auth, db).select_tenant(request=request, redis=redis, tenant_id=data.tenant_id)
|
||
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
|
||
return SuccessResponse(data=result, msg="租户切换成功")
|
||
|
||
|
||
@AuthRouter.post("/enter-platform", summary="进入平台管理模式", response_model=ResponseSchema[EnterPlatformOutSchema], dependencies=[Depends(get_current_user)])
|
||
async def enter_platform_controller(
|
||
request: Request,
|
||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||
redis: Annotated[Redis, Depends(redis_getter)],
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
) -> JSONResponse:
|
||
result = await LoginService(auth, db).enter_platform(request=request, redis=redis)
|
||
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
|
||
return SuccessResponse(data=result, msg="已返回平台管理模式")
|
||
|
||
|
||
@AuthRouter.get("/tenants", summary="获取可选租户列表", response_model=ResponseSchema[list[TenantOptionSchema]], dependencies=[Depends(get_current_user)])
|
||
@cache(expire=120, namespace=_AUTH_TENANTS_NS)
|
||
async def get_user_tenants_controller(
|
||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
) -> JSONResponse:
|
||
service = LoginService(auth, db)
|
||
tenants = await service.get_user_tenants()
|
||
return SuccessResponse(data=tenants, msg="获取租户列表成功")
|
||
|
||
|
||
@AuthRouter.post("/impersonate", summary="平台管理员代签入", response_model=ResponseSchema[ImpersonateOutSchema], dependencies=[Depends(get_current_user)])
|
||
async def impersonate_controller(
|
||
request: Request,
|
||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||
redis: Annotated[Redis, Depends(redis_getter)],
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
data: Annotated[ImpersonateSchema, Body(description="代签入参数")],
|
||
) -> JSONResponse:
|
||
result = await LoginService(auth, db).impersonate(request=request, redis=redis, tenant_id=data.tenant_id)
|
||
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
|
||
return SuccessResponse(data=result, msg="代签入成功")
|
||
|
||
|
||
@AuthRouter.get("/oauth/{provider}/login", summary="第三方OAuth跳转")
|
||
async def oauth_login_redirect_controller(
|
||
request: Request,
|
||
redis: Annotated[Redis, Depends(redis_getter)],
|
||
provider: Annotated[OAuthProvider, Path(description="wechat | qq | github | gitee")],
|
||
redirect_uri: Annotated[str | None, Query(description="OAuth 完成后浏览器回到的前端登录页完整 URL")] = None,
|
||
) -> RedirectResponse:
|
||
allowed = {"wechat", "qq", "github", "gitee"}
|
||
fe = redirect_uri or settings.OAUTH_FRONTEND_FALLBACK
|
||
if provider not in allowed:
|
||
return RedirectContentResponse(
|
||
url=oauth_service_error_redirect(fe, "不支持的 OAuth 渠道"),
|
||
status_code=302,
|
||
)
|
||
if not redirect_uri:
|
||
return RedirectContentResponse(
|
||
url=oauth_service_error_redirect(fe, "缺少 redirect_uri 参数"),
|
||
status_code=302,
|
||
)
|
||
try:
|
||
state = secrets.token_urlsafe(32)
|
||
await save_oauth_state(
|
||
redis=redis,
|
||
state=state,
|
||
provider=provider,
|
||
frontend_redirect=redirect_uri,
|
||
)
|
||
cb = _callback_url(request, provider)
|
||
url = build_authorize_url(provider=provider, callback_url=cb, state=state)
|
||
return RedirectContentResponse(url=url, status_code=302)
|
||
except CustomException as e:
|
||
return RedirectContentResponse(
|
||
url=oauth_service_error_redirect(redirect_uri, e.msg),
|
||
status_code=302,
|
||
)
|
||
|
||
|
||
@AuthRouter.get("/oauth/{provider}/callback", summary="第三方OAuth回调", include_in_schema=False)
|
||
async def oauth_callback_controller(
|
||
request: Request,
|
||
redis: Annotated[Redis, Depends(redis_getter)],
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
provider: Annotated[OAuthProvider, Path(description="wechat | qq | github | gitee")],
|
||
code: Annotated[str | None, Query(description="OAuth 授权码")] = None,
|
||
state: Annotated[str | None, Query(description="OAuth 状态参数")] = None,
|
||
) -> RedirectResponse:
|
||
fe_fallback = settings.OAUTH_FRONTEND_FALLBACK
|
||
|
||
async def resolve_frontend() -> str:
|
||
if not state:
|
||
return fe_fallback
|
||
raw = await RedisCURD(redis).get(f"{STATE_PREFIX}{state}")
|
||
if not raw:
|
||
return fe_fallback
|
||
if isinstance(raw, bytes):
|
||
raw = raw.decode("utf-8")
|
||
try:
|
||
payload = json.loads(raw)
|
||
return str(payload.get("frontend_redirect") or fe_fallback).strip() or fe_fallback
|
||
except json.JSONDecodeError:
|
||
return fe_fallback
|
||
|
||
if provider not in {"wechat", "qq", "github", "gitee"}:
|
||
url = oauth_service_error_redirect(await resolve_frontend(), "不支持的 OAuth 渠道")
|
||
return RedirectContentResponse(url=url, status_code=302)
|
||
if not code or not state:
|
||
url = oauth_service_error_redirect(await resolve_frontend(), "授权被取消或参数不完整")
|
||
return RedirectContentResponse(url=url, status_code=302)
|
||
try:
|
||
token, fe = await complete_oauth_login(
|
||
request=request,
|
||
redis=redis,
|
||
db=db,
|
||
provider=provider,
|
||
code=code,
|
||
state=state,
|
||
)
|
||
success_url = oauth_service_frontend_redirect_from_token(fe, token)
|
||
return RedirectContentResponse(url=success_url, status_code=302)
|
||
except CustomException as e:
|
||
fe = await resolve_frontend()
|
||
return RedirectContentResponse(url=oauth_service_error_redirect(fe, e.msg), status_code=302)
|
||
|
||
|
||
@AuthRouter.post("/tenant/register", status_code=status.HTTP_201_CREATED, summary="租户自助注册", response_model=ResponseSchema[TenantRegisterOutSchema])
|
||
async def tenant_register_controller(
|
||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||
data: Annotated[TenantRegisterSchema, Body(description="租户注册参数")],
|
||
) -> JSONResponse:
|
||
result = await TenantRegisterService.register(
|
||
db=db,
|
||
username=data.username,
|
||
password=data.password,
|
||
email=data.email,
|
||
tenant_name=data.tenant_name,
|
||
)
|
||
logger.info(f"新租户注册: username={data.username} tenant={result.tenant_name}")
|
||
return SuccessResponse(data=result, msg=result.message)
|