Files
FastapiAdmin/backend/app/api/v1/module_system/auth/controller.py
T
zhangtao cf88ab8897 refactor: 整合仪表盘功能到监控模块,清理冗余代码
- 移除原监控仪表盘独立模块,将相关功能合并到在线监控模块
- 重构租户配置字段名,统一使用logo_url和name替代tenant_logo/tenant_name
- 优化搜索工具函数,移除重复导入
- 调整参数配置模型字段长度限制,移除config_value的max_length约束
- 清理冗余的常量定义和导入语句
- 修复批量状态设置接口的redis依赖注入
- 增强OAuth登录安全性,添加租户默认归属和state一次性消费
- 优化资源目录缓存逻辑,减少重复计算
- 新增API Token模块基础框架
- 完善用户token版本管理,支持主动失效JWT
- 调整AI模型配置缓存过期时间
- 修复菜单类型字段索引,提升查询性能
- 简化前端刷新token调用逻辑
- 新增滑块验证完成接口和忘记密码验证码校验
- 调整系统配置默认值,添加操作日志保留天数和接口白名单配置
- 限制Mock支付回调仅在开发环境可用
- 重构websocket认证方式,支持更安全的subprotocol传参
2026-07-13 01:14:20 +08:00

300 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)