""" 认证控制器 — TODO: 限流粒度细化 --------------------------------- 当前登录(/login)和 OAuth 端点(/oauth/*)共享应用的通用限流配置, 缺少独立的、更严格的限流策略。建议为以下端点配置独立的 RateLimiter: 1. /auth/login — 密码登录 - 建议: 按 IP + 用户名组合限流,如 5次/分钟/IP + 10次/15分钟/用户 - 原因: 暴力破解防护 2. /auth/oauth/* — 第三方 OAuth 登录/回调 - 建议: 按 IP 限流,如 10次/分钟/IP - 原因: OAuth 流程可能触发多次重定向,频率稍高于登录 3. /auth/captcha/* — 验证码获取/校验 - 建议: 按 IP 限流,如 3次/分钟/IP - 原因: 防止验证码遍历 """ import json import secrets from typing import Annotated from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query, Request from fastapi.responses import JSONResponse, RedirectResponse from redis.asyncio.client import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.common.enums import EnvironmentEnum from app.common.response import ErrorResponse, RedirectContentResponse, ResponseSchema, SuccessResponse from app.config.setting import settings from app.core.base_schema import 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, LoginOutSchema, SliderCompleteOutSchema, SliderCompleteSchema, ) from .service import ( CaptchaService, LoginService, ) AuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/auth", tags=["认证授权"]) @AuthRouter.post("/login", summary="登录", response_model=LoginOutSchema) 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 | LoginOutSchema: 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.get("/login-default", summary="开发环境登录默认值", response_model=ResponseSchema[dict]) async def get_login_default_controller() -> JSONResponse: """开发阶段便利接口:返回预设的登录默认值(仅 DEV 环境有效)。 - DEV 且 settings.DEV_DEFAULT_PASSWORD 非空时:返回 {"username": "super", "password": "<配置值>"}。 - 非 DEV 或密码为空:返回 {"username": "", "password": ""},前端不预填。 """ if settings.ENVIRONMENT == EnvironmentEnum.DEV and settings.DEV_DEFAULT_PASSWORD: data = {"username": "super", "password": settings.DEV_DEFAULT_PASSWORD} else: data = {"username": "", "password": ""} return SuccessResponse(data=data, msg="ok") @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.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)