init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
认证控制器 — 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)
|
||||
@@ -0,0 +1,442 @@
|
||||
"""第三方 OAuth2 登录(微信开放平台扫码、QQ、GitHub、Gitee)。
|
||||
|
||||
各平台需在开放平台登记「授权回调域 / redirect_uri」为:
|
||||
{API}/system/auth/oauth/{provider}/callback
|
||||
例如:https://your-domain.com/api/v1/system/auth/oauth/github/callback
|
||||
|
||||
环境变量见 Settings 中 OAUTH_* 字段。
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from redis.asyncio.client import Redis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.schema import UserCreateSchema
|
||||
from app.api.v1.module_system.user.service import UserService
|
||||
from app.config.setting import settings
|
||||
from app.core.base_schema import AuthSchema, JWTOutSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.core.redis_crud import RedisCURD
|
||||
|
||||
from .service import LoginService
|
||||
|
||||
OAuthProvider = Literal["wechat", "qq", "github", "gitee"]
|
||||
|
||||
STATE_PREFIX = "oauth_state:"
|
||||
|
||||
|
||||
def _callback_url(request: Request, provider: OAuthProvider) -> str:
|
||||
root = str(request.base_url).rstrip("/")
|
||||
# 域名白名单校验:防止 Host 头注入攻击重定向到恶意域名
|
||||
allowed_hosts = settings.OAUTH_ALLOWED_HOSTS
|
||||
if allowed_hosts and allowed_hosts != ["*"]:
|
||||
host = request.url.hostname
|
||||
if host is None or not any(host == allowed_host or host.endswith("." + allowed_host) for allowed_host in allowed_hosts):
|
||||
raise CustomException(msg="非法的 OAuth 回调域名")
|
||||
return f"{root}/system/auth/oauth/{provider}/callback"
|
||||
|
||||
|
||||
def _frontend_error_redirect(frontend_base: str, message: str) -> str:
|
||||
sep = "&" if "?" in frontend_base else "?"
|
||||
return f"{frontend_base}{sep}oauth_error={quote(message, safe='')}"
|
||||
|
||||
|
||||
def _frontend_success_redirect(frontend_base: str, access_token: str, refresh_token: str, token_type: str) -> str:
|
||||
q = urlencode(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": token_type,
|
||||
},
|
||||
)
|
||||
sep = "&" if "?" in frontend_base else "?"
|
||||
return f"{frontend_base}{sep}{q}"
|
||||
|
||||
|
||||
def _require_credentials(provider: OAuthProvider) -> tuple[str, str]:
|
||||
if provider == "github":
|
||||
cid, sec = settings.OAUTH_GITHUB_CLIENT_ID, settings.OAUTH_GITHUB_CLIENT_SECRET
|
||||
elif provider == "gitee":
|
||||
cid, sec = settings.OAUTH_GITEE_CLIENT_ID, settings.OAUTH_GITEE_CLIENT_SECRET
|
||||
elif provider == "wechat":
|
||||
cid, sec = settings.OAUTH_WECHAT_OPEN_APP_ID, settings.OAUTH_WECHAT_OPEN_APP_SECRET
|
||||
elif provider == "qq":
|
||||
cid, sec = settings.OAUTH_QQ_APP_ID, settings.OAUTH_QQ_APP_SECRET
|
||||
else:
|
||||
raise CustomException(msg="不支持的 OAuth 渠道")
|
||||
if not cid or not sec:
|
||||
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)")
|
||||
return cid, sec
|
||||
|
||||
|
||||
def build_authorize_url(
|
||||
*,
|
||||
provider: OAuthProvider,
|
||||
callback_url: str,
|
||||
state: str,
|
||||
) -> str:
|
||||
"""构造跳转至第三方授权页的 URL。"""
|
||||
cid, _ = _require_credentials(provider)
|
||||
|
||||
if provider == "github":
|
||||
params = {
|
||||
"client_id": cid,
|
||||
"redirect_uri": callback_url,
|
||||
"scope": "user:email",
|
||||
"state": state,
|
||||
}
|
||||
return "https://github.com/login/oauth/authorize?" + urlencode(params)
|
||||
|
||||
if provider == "gitee":
|
||||
params = {
|
||||
"client_id": cid,
|
||||
"redirect_uri": callback_url,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
}
|
||||
return "https://gitee.com/oauth/authorize?" + urlencode(params)
|
||||
|
||||
if provider == "wechat":
|
||||
params = {
|
||||
"appid": cid,
|
||||
"redirect_uri": callback_url,
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_login",
|
||||
"state": state,
|
||||
}
|
||||
return "https://open.weixin.qq.com/connect/qrconnect?" + urlencode(params) + "#wechat_redirect"
|
||||
|
||||
if provider == "qq":
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": cid,
|
||||
"redirect_uri": callback_url,
|
||||
"state": state,
|
||||
"scope": "get_user_info",
|
||||
}
|
||||
return "https://graph.qq.com/oauth2.0/authorize?" + urlencode(params)
|
||||
|
||||
raise CustomException(msg="不支持的 OAuth 渠道")
|
||||
|
||||
|
||||
async def _http_json(method: str, url: str, **kwargs: Any) -> Any:
|
||||
timeout = getattr(settings, "HTTPX_DEFAULT_TIMEOUT", 15.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.request(method, url, **kwargs)
|
||||
r.raise_for_status()
|
||||
try:
|
||||
return r.json()
|
||||
except json.JSONDecodeError:
|
||||
text = r.text
|
||||
logger.error(f"OAuth 非 JSON 响应: {text[:500]}")
|
||||
raise CustomException(msg="OAuth 接口返回异常")
|
||||
|
||||
|
||||
async def _http_text(method: str, url: str, **kwargs: Any) -> str:
|
||||
timeout = getattr(settings, "HTTPX_DEFAULT_TIMEOUT", 15.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.request(method, url, **kwargs)
|
||||
r.raise_for_status()
|
||||
return r.text
|
||||
|
||||
|
||||
async def exchange_github_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> str:
|
||||
data = await _http_json(
|
||||
"POST",
|
||||
"https://github.com/login/oauth/access_token",
|
||||
headers={"Accept": "application/json"},
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise CustomException(msg="GitHub token 响应格式错误")
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise CustomException(msg=data.get("error_description") or "GitHub 换取令牌失败")
|
||||
return str(token)
|
||||
|
||||
|
||||
async def exchange_gitee_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> str:
|
||||
qs = urlencode(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
)
|
||||
data = await _http_json("GET", f"https://gitee.com/oauth/token?{qs}")
|
||||
if not isinstance(data, dict):
|
||||
raise CustomException(msg="Gitee token 响应格式错误")
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise CustomException(msg=data.get("error_description") or "Gitee 换取令牌失败")
|
||||
return str(token)
|
||||
|
||||
|
||||
async def exchange_wechat_token(app_id: str, secret: str, code: str) -> tuple[str, str]:
|
||||
qs = urlencode(
|
||||
{
|
||||
"appid": app_id,
|
||||
"secret": secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
data = await _http_json("GET", f"https://api.weixin.qq.com/sns/oauth2/access_token?{qs}")
|
||||
if not isinstance(data, dict):
|
||||
raise CustomException(msg="微信 token 响应格式错误")
|
||||
token = data.get("access_token")
|
||||
openid = data.get("openid")
|
||||
if not token or not openid:
|
||||
raise CustomException(msg=data.get("errmsg") or "微信换取令牌失败")
|
||||
return str(token), str(openid)
|
||||
|
||||
|
||||
async def exchange_qq_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> tuple[str, str]:
|
||||
qs = urlencode(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
)
|
||||
text = await _http_text("GET", f"https://graph.qq.com/oauth2.0/token?{qs}")
|
||||
parts = dict(p.split("=", 1) for p in text.split("&") if "=" in p)
|
||||
token = parts.get("access_token")
|
||||
if not token:
|
||||
raise CustomException(msg="QQ 换取 access_token 失败")
|
||||
me = await _http_json(
|
||||
"GET",
|
||||
"https://graph.qq.com/oauth2.0/me",
|
||||
params={"access_token": token, "fmt": "json"},
|
||||
)
|
||||
if not isinstance(me, dict):
|
||||
raise CustomException(msg="QQ openid 响应格式错误")
|
||||
openid = me.get("openid")
|
||||
if not openid:
|
||||
raise CustomException(msg="QQ 获取 openid 失败")
|
||||
return str(token), str(openid)
|
||||
|
||||
|
||||
async def fetch_github_profile(access_token: str) -> tuple[str, str, str | None]:
|
||||
headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
|
||||
user = await _http_json("GET", "https://api.github.com/user", headers=headers)
|
||||
if not isinstance(user, dict):
|
||||
raise CustomException(msg="GitHub 用户信息格式错误")
|
||||
login = str(user.get("login") or "")
|
||||
name = str(user.get("name") or login or "github")
|
||||
email = user.get("email")
|
||||
if not email:
|
||||
emails = await _http_json("GET", "https://api.github.com/user/emails", headers=headers)
|
||||
if isinstance(emails, list):
|
||||
primary = next((e for e in emails if isinstance(e, dict) and e.get("primary")), None)
|
||||
if primary:
|
||||
email = primary.get("email")
|
||||
return login, name, email
|
||||
|
||||
|
||||
async def fetch_gitee_profile(access_token: str) -> tuple[str, str, str | None]:
|
||||
user = await _http_json(
|
||||
"GET",
|
||||
"https://gitee.com/api/v5/user",
|
||||
params={"access_token": access_token},
|
||||
)
|
||||
if not isinstance(user, dict):
|
||||
raise CustomException(msg="Gitee 用户信息格式错误")
|
||||
login = str(user.get("login") or "")
|
||||
name = str(user.get("name") or login)
|
||||
email = user.get("email")
|
||||
return login, name, email
|
||||
|
||||
|
||||
async def fetch_wechat_profile(access_token: str, openid: str) -> tuple[str, str]:
|
||||
qs = urlencode({"access_token": access_token, "openid": openid, "lang": "zh_CN"})
|
||||
user = await _http_json("GET", f"https://api.weixin.qq.com/sns/userinfo?{qs}")
|
||||
if not isinstance(user, dict):
|
||||
raise CustomException(msg="微信用户信息格式错误")
|
||||
nickname = str(user.get("nickname") or "wechat")
|
||||
unionid = user.get("unionid")
|
||||
oid = unionid or openid
|
||||
return str(oid), nickname
|
||||
|
||||
|
||||
async def fetch_qq_profile(access_token: str, app_id: str, openid: str) -> tuple[str, str]:
|
||||
qs = urlencode(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"oauth_consumer_key": app_id,
|
||||
"openid": openid,
|
||||
},
|
||||
)
|
||||
user = await _http_json("GET", f"https://graph.qq.com/user/get_user_info?{qs}")
|
||||
if not isinstance(user, dict):
|
||||
raise CustomException(msg="QQ 用户信息格式错误")
|
||||
if user.get("ret") not in (0, "0", None):
|
||||
raise CustomException(msg=user.get("msg") or "QQ 用户信息失败")
|
||||
nickname = str(user.get("nickname") or "qq")
|
||||
return openid, nickname
|
||||
|
||||
|
||||
def _username_for_oauth(provider: OAuthProvider, unique_id: str) -> str:
|
||||
"""生成符合注册规则的登录名:oauth_{provider}_{id}。"""
|
||||
raw = f"oauth_{provider}_{unique_id}"
|
||||
raw = "".join(c if c.isalnum() or c in "_-." else "_" for c in raw)[:32]
|
||||
if len(raw) < 3:
|
||||
raw = (raw + "usr")[:32]
|
||||
if not raw[0].isalpha():
|
||||
raw = "o" + raw[:31]
|
||||
return raw
|
||||
|
||||
|
||||
async def ensure_oauth_user(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
provider: OAuthProvider,
|
||||
unique_id: str,
|
||||
display_name: str,
|
||||
) -> UserModel:
|
||||
auth = AuthSchema()
|
||||
username = _username_for_oauth(provider, unique_id)
|
||||
existing = await UserCRUD(auth, db).get(username=username)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
reg = UserCreateSchema(
|
||||
username=username,
|
||||
password=secrets.token_urlsafe(24),
|
||||
name=(display_name or username)[:32],
|
||||
role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS),
|
||||
)
|
||||
try:
|
||||
await UserService(auth, db).create(data=reg)
|
||||
except Exception:
|
||||
# 并发创建可能触发唯一约束冲突,回退到再次查询
|
||||
existing = await UserCRUD(auth, db).get(username=username)
|
||||
if existing:
|
||||
return existing
|
||||
raise CustomException(msg="OAuth 注册失败")
|
||||
user = await UserCRUD(auth, db).get(username=username)
|
||||
if not user:
|
||||
raise CustomException(msg="OAuth 注册失败")
|
||||
logger.info(f"OAuth 自动注册用户: {username} ({provider})")
|
||||
return user
|
||||
|
||||
|
||||
async def complete_oauth_login(
|
||||
*,
|
||||
request: Request,
|
||||
redis: Redis,
|
||||
db: AsyncSession,
|
||||
provider: OAuthProvider,
|
||||
code: str,
|
||||
state: str,
|
||||
) -> 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):
|
||||
raw = raw.decode("utf-8")
|
||||
payload = json.loads(raw)
|
||||
if payload.get("provider") != provider:
|
||||
raise CustomException(msg="OAuth 状态不匹配")
|
||||
|
||||
frontend = str(payload.get("frontend_redirect") or "").strip()
|
||||
if not frontend:
|
||||
raise CustomException(msg="缺少前端回调地址")
|
||||
|
||||
callback_url = _callback_url(request, provider)
|
||||
cid, csec = _require_credentials(provider)
|
||||
|
||||
if provider == "github":
|
||||
access = await exchange_github_token(cid, csec, code, callback_url)
|
||||
login_k, name, _email = await fetch_github_profile(access)
|
||||
uid = login_k
|
||||
elif provider == "gitee":
|
||||
access = await exchange_gitee_token(cid, csec, code, callback_url)
|
||||
login_k, name, _email = await fetch_gitee_profile(access)
|
||||
uid = login_k
|
||||
elif provider == "wechat":
|
||||
access, openid = await exchange_wechat_token(cid, csec, code)
|
||||
uid, name = await fetch_wechat_profile(access, openid)
|
||||
elif provider == "qq":
|
||||
access, openid = await exchange_qq_token(cid, csec, code, callback_url)
|
||||
uid, name = await fetch_qq_profile(access, cid, openid)
|
||||
else:
|
||||
raise CustomException(msg="不支持的 OAuth 渠道")
|
||||
|
||||
user = await ensure_oauth_user(db=db, provider=provider, unique_id=uid, display_name=name)
|
||||
try:
|
||||
if user.status == 1:
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
user = await UserCRUD(AuthSchema(), db).update_last_login(id=user.id)
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
|
||||
login_type = f"oauth_{provider}"
|
||||
token = await LoginService.create_token(request=request, redis=redis, user=user, login_type=login_type)
|
||||
return token, frontend
|
||||
finally:
|
||||
await rc.delete(f"{STATE_PREFIX}{state}")
|
||||
|
||||
|
||||
async def save_oauth_state(
|
||||
*,
|
||||
redis: Redis,
|
||||
state: str,
|
||||
provider: OAuthProvider,
|
||||
frontend_redirect: str,
|
||||
) -> None:
|
||||
rc = RedisCURD(redis)
|
||||
ok = await rc.set(
|
||||
f"{STATE_PREFIX}{state}",
|
||||
json.dumps({"provider": provider, "frontend_redirect": frontend_redirect}),
|
||||
expire=settings.OAUTH_STATE_TTL,
|
||||
)
|
||||
if not ok:
|
||||
raise CustomException(msg="缓存 OAuth 状态失败")
|
||||
|
||||
|
||||
def oauth_service_frontend_redirect_from_token(frontend_base: str, token: JWTOutSchema) -> str:
|
||||
return _frontend_success_redirect(
|
||||
frontend_base,
|
||||
token.access_token,
|
||||
token.refresh_token,
|
||||
token.token_type,
|
||||
)
|
||||
|
||||
|
||||
def oauth_service_error_redirect(frontend_base: str, message: str) -> str:
|
||||
return _frontend_error_redirect(frontend_base, message)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"STATE_PREFIX",
|
||||
"OAuthProvider",
|
||||
"_callback_url",
|
||||
"build_authorize_url",
|
||||
"complete_oauth_login",
|
||||
"oauth_service_error_redirect",
|
||||
"oauth_service_frontend_redirect_from_token",
|
||||
"save_oauth_state",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.base_schema import JWTOutSchema
|
||||
|
||||
|
||||
class CaptchaOutSchema(BaseModel):
|
||||
"""验证码响应模型"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
enable: bool = Field(default=True, description="是否启用验证码")
|
||||
key: str = Field(default="", description="验证码唯一标识(未启用时为空字符串)")
|
||||
img_base: str = Field(default="", description="Base64编码的验证码图片(滑块模式为空字符串)")
|
||||
|
||||
|
||||
class LoginOutSchema(JWTOutSchema):
|
||||
"""登录响应"""
|
||||
|
||||
user_info: dict[str, Any] = Field(default_factory=dict, 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="是否验证通过")
|
||||
@@ -0,0 +1,562 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, NewType
|
||||
|
||||
import ua_parser
|
||||
from fastapi import BackgroundTasks, Request
|
||||
from redis.asyncio.client import Redis
|
||||
from sqlalchemy import update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.user.crud import UserCRUD
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
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.security import (
|
||||
CustomOAuth2PasswordRequestForm,
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
)
|
||||
from app.utils.common_util import get_random_character
|
||||
from app.utils.ip_local_util import IpLocalUtil, get_client_ip
|
||||
from app.utils.password_util import PwdUtil
|
||||
|
||||
from .schema import (
|
||||
CaptchaOutSchema,
|
||||
LoginOutSchema,
|
||||
)
|
||||
|
||||
CaptchaKey = NewType("CaptchaKey", str)
|
||||
CaptchaBase64 = NewType("CaptchaBase64", str)
|
||||
|
||||
_LOGIN_FAIL_PREFIX = "login:fail:"
|
||||
_LOGIN_LOCK_PREFIX = "login:lock:"
|
||||
|
||||
|
||||
def _login_fail_key(username: str, ip: str) -> str:
|
||||
return f"{_LOGIN_FAIL_PREFIX}{username}:{ip}"
|
||||
|
||||
|
||||
def _login_lock_key(username: str, ip: str) -> str:
|
||||
return f"{_LOGIN_LOCK_PREFIX}{username}:{ip}"
|
||||
|
||||
|
||||
async def _record_login_failure(redis: Redis, username: str, ip: str) -> None:
|
||||
"""记录一次登录失败;连续失败达到阈值后锁定该用户名+IP。"""
|
||||
try:
|
||||
fail_key = _login_fail_key(username, ip)
|
||||
count = await redis.incr(fail_key)
|
||||
if count == 1:
|
||||
await redis.expire(fail_key, settings.LOGIN_FAILURE_WINDOW_SECONDS)
|
||||
if count >= settings.LOGIN_MAX_FAILURES:
|
||||
lock_key = _login_lock_key(username, ip)
|
||||
await redis.set(lock_key, "1", ex=settings.LOGIN_LOCKOUT_SECONDS)
|
||||
# 锁定后清空计数,避免解锁瞬间因残留计数再次触发锁定
|
||||
await redis.delete(fail_key)
|
||||
logger.warning("登录失败次数过多,已锁定: username={} ip={}", username, ip)
|
||||
except Exception as e:
|
||||
# 失败计数不影响登录主流程,仅记录
|
||||
logger.error("记录登录失败状态失败: {}", e)
|
||||
|
||||
|
||||
async def _clear_login_failures(redis: Redis, username: str, ip: str) -> None:
|
||||
"""登录成功后清除失败计数与锁定标记。"""
|
||||
try:
|
||||
await redis.delete(_login_fail_key(username, ip), _login_lock_key(username, ip))
|
||||
except Exception as e:
|
||||
logger.error("清除登录失败计数失败: {}", e)
|
||||
|
||||
|
||||
async def _write_login_log(
|
||||
username: str,
|
||||
status: int,
|
||||
login_ip: str | None = None,
|
||||
login_location: str | None = None,
|
||||
request_os: str | None = None,
|
||||
request_browser: str | None = None,
|
||||
msg: str | None = None,
|
||||
) -> int | None:
|
||||
"""写入登录日志;返回日志 ID(用于后台补全归属地)。"""
|
||||
try:
|
||||
async with async_db_session() as session, session.begin():
|
||||
_auth = AuthSchema()
|
||||
obj = await LoginLogCRUD(_auth, session).create(
|
||||
data=LoginLogCreateSchema(
|
||||
username=username,
|
||||
status=status,
|
||||
login_ip=login_ip,
|
||||
login_location=login_location,
|
||||
request_os=request_os,
|
||||
request_browser=request_browser,
|
||||
msg=msg,
|
||||
),
|
||||
)
|
||||
return obj.id if obj else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _async_fill_login_location(redis, login_log_id: int, ip: str | None) -> None:
|
||||
"""后台异步补全登录日志的归属地。"""
|
||||
if not ip:
|
||||
return
|
||||
try:
|
||||
location = await IpLocalUtil.resolve_location_async(redis, ip)
|
||||
logger.info(f"异步解析IP归属地结果: ip={ip}, log_id={login_log_id}, location={location}")
|
||||
if location == "归属地查询中" or not location:
|
||||
return
|
||||
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}")
|
||||
except Exception as e:
|
||||
logger.warning(f"异步补全登录归属地失败: {e}")
|
||||
|
||||
|
||||
class LoginService:
|
||||
"""登录认证服务"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _collect_permissions(
|
||||
user: UserModel,
|
||||
) -> tuple[list[str], list[int]]:
|
||||
"""收集用户角色下的权限和菜单 ID
|
||||
|
||||
参数:
|
||||
- user (UserModel): 用户对象
|
||||
|
||||
返回:
|
||||
- tuple[list[str], list[int]]: (permissions, menu_ids)
|
||||
"""
|
||||
permissions: list[str] = []
|
||||
menu_ids: list[int] = []
|
||||
if not user.is_superuser and hasattr(user, "roles"):
|
||||
for role in user.roles:
|
||||
if role and role.status == 0:
|
||||
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)
|
||||
return permissions, menu_ids
|
||||
|
||||
@classmethod
|
||||
async def authenticate_user(
|
||||
cls,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
redis: Redis,
|
||||
login_form: CustomOAuth2PasswordRequestForm,
|
||||
db: AsyncSession,
|
||||
) -> LoginOutSchema:
|
||||
"""用户认证"""
|
||||
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)
|
||||
|
||||
# 暴力破解防护:先检查该用户名+IP 是否已被锁定(Redis 故障时跳过,避免锁死系统)
|
||||
lock_key = _login_lock_key(login_form.username, request_ip)
|
||||
try:
|
||||
locked = await redis.get(lock_key)
|
||||
except Exception:
|
||||
locked = None
|
||||
if locked:
|
||||
raise CustomException(
|
||||
msg=f"登录失败次数过多,账号已临时锁定,请 {settings.LOGIN_LOCKOUT_SECONDS // 60} 分钟后再试",
|
||||
)
|
||||
_login_os = ua_result.os.family if ua_result.os else "Unknown"
|
||||
_login_browser = ua_result.user_agent.family if ua_result.user_agent else "Unknown"
|
||||
_login_username = login_form.username
|
||||
|
||||
referer = request.headers.get("referer", "")
|
||||
request_from_docs = referer.endswith(("docs", "redoc"))
|
||||
|
||||
if settings.CAPTCHA_ENABLE and not request_from_docs:
|
||||
if not login_form.captcha_key:
|
||||
raise CustomException(msg="验证码不能为空")
|
||||
# 滑块模式:slider_complete 已验证身份,此处仅校验状态
|
||||
await CaptchaService.check_captcha(
|
||||
redis=redis,
|
||||
key=login_form.captcha_key,
|
||||
)
|
||||
|
||||
auth = AuthSchema()
|
||||
user = await UserCRUD(auth, db).get(username=login_form.username, preload=["roles", "roles.menus"])
|
||||
|
||||
if not user:
|
||||
await _record_login_failure(redis, _login_username, request_ip)
|
||||
await _write_login_log(
|
||||
username=_login_username,
|
||||
status=2,
|
||||
login_ip=request_ip,
|
||||
login_location=login_location,
|
||||
request_os=_login_os,
|
||||
request_browser=_login_browser,
|
||||
msg="用户不存在",
|
||||
)
|
||||
raise CustomException(msg="用户不存在")
|
||||
|
||||
if not PwdUtil.verify_password(plain_password=login_form.password, password_hash=user.password):
|
||||
await _record_login_failure(redis, _login_username, request_ip)
|
||||
await _write_login_log(
|
||||
username=_login_username,
|
||||
status=2,
|
||||
login_ip=request_ip,
|
||||
login_location=login_location,
|
||||
request_os=_login_os,
|
||||
request_browser=_login_browser,
|
||||
msg="账号或密码错误",
|
||||
)
|
||||
raise CustomException(msg="账号或密码错误")
|
||||
if user.status == 1:
|
||||
await _write_login_log(
|
||||
username=_login_username,
|
||||
status=2,
|
||||
login_ip=request_ip,
|
||||
login_location=login_location,
|
||||
request_os=_login_os,
|
||||
request_browser=_login_browser,
|
||||
msg="用户已被停用",
|
||||
)
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
await UserCRUD(auth, db).update_last_login(id=user.id)
|
||||
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
if not login_form.login_type:
|
||||
raise CustomException(msg="登录类型不能为空")
|
||||
|
||||
token = await cls.create_token(
|
||||
request=request,
|
||||
redis=redis,
|
||||
user=user,
|
||||
login_type=login_form.login_type,
|
||||
)
|
||||
# 登录成功:清除该用户名+IP 的失败计数与锁定
|
||||
await _clear_login_failures(redis, user.username, request_ip)
|
||||
|
||||
user_info = {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"name": user.name,
|
||||
"avatar": user.avatar,
|
||||
"is_superuser": user.is_superuser,
|
||||
}
|
||||
|
||||
log_id = await _write_login_log(
|
||||
username=user.username,
|
||||
status=1,
|
||||
login_ip=request_ip,
|
||||
login_location=login_location,
|
||||
request_os=_login_os,
|
||||
request_browser=_login_browser,
|
||||
msg="登录成功",
|
||||
)
|
||||
# 登录成功后异步补全归属地,不阻塞返回
|
||||
if log_id and login_location == "归属地查询中":
|
||||
background_tasks.add_task(_async_fill_login_location, redis, log_id, request_ip)
|
||||
|
||||
return LoginOutSchema(
|
||||
access_token=token.access_token,
|
||||
refresh_token=token.refresh_token,
|
||||
expires_in=token.expires_in,
|
||||
token_type=token.token_type,
|
||||
user_info=user_info,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_session_dict(
|
||||
user: UserModel,
|
||||
session_id: str,
|
||||
permissions: list[str],
|
||||
menu_ids: list[int],
|
||||
request_ip: str,
|
||||
login_location: str | None,
|
||||
ua_result: Any,
|
||||
login_type: str,
|
||||
) -> dict:
|
||||
"""构建会话信息字典
|
||||
|
||||
参数:
|
||||
- user (UserModel): 用户对象
|
||||
- session_id (str): 会话ID
|
||||
- permissions (list[str]): 权限标识列表
|
||||
- menu_ids (list[int]): 菜单ID列表
|
||||
- request_ip (str): 请求IP
|
||||
- login_location (str): 登录地点
|
||||
- ua_result: User-Agent 解析结果
|
||||
- login_type (str): 登录类型
|
||||
|
||||
返回:
|
||||
- dict: 会话信息字典
|
||||
"""
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"user_id": user.id,
|
||||
"is_superuser": user.is_superuser,
|
||||
"user_status": user.status,
|
||||
"name": user.name,
|
||||
"user_name": user.username,
|
||||
"dept_id": user.dept_id,
|
||||
"mobile": user.mobile,
|
||||
"email": user.email,
|
||||
"gender": user.gender,
|
||||
"avatar": user.avatar,
|
||||
"permissions": permissions,
|
||||
"menu_ids": menu_ids,
|
||||
"ipaddr": request_ip,
|
||||
"login_location": login_location,
|
||||
"os": ua_result.os.family if ua_result.os else "Unknown",
|
||||
"browser": ua_result.user_agent.family if ua_result.user_agent else "Unknown",
|
||||
"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)
|
||||
|
||||
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
permissions, menu_ids = LoginService._collect_permissions(user)
|
||||
|
||||
session_dict = LoginService._build_session_dict(
|
||||
user=user,
|
||||
session_id=session_id,
|
||||
permissions=permissions,
|
||||
menu_ids=menu_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
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}",
|
||||
value=session_info,
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=False,
|
||||
exp=now + access_expires,
|
||||
),
|
||||
)
|
||||
refresh_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=True,
|
||||
exp=now + refresh_expires,
|
||||
),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
|
||||
value=access_token,
|
||||
expire=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
|
||||
value=refresh_token,
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
)
|
||||
|
||||
return JWTOutSchema(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=int(access_expires.total_seconds()),
|
||||
token_type=settings.TOKEN_TYPE,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def refresh_token(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
redis: Redis,
|
||||
refresh_token: str,
|
||||
) -> JWTOutSchema:
|
||||
"""刷新访问令牌"""
|
||||
token_payload: JWTPayloadSchema = decode_access_token(token=refresh_token)
|
||||
if not token_payload.is_refresh:
|
||||
raise CustomException(msg="非法凭证,请传入刷新令牌")
|
||||
|
||||
session_id = token_payload.sub
|
||||
session_info = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
|
||||
if not session_info:
|
||||
raise CustomException(msg="会话已过期,请重新登录")
|
||||
|
||||
user_id = json.loads(session_info).get("user_id")
|
||||
|
||||
if not session_id or not user_id:
|
||||
raise CustomException(msg="非法凭证,无法获取会话编号或用户ID")
|
||||
|
||||
auth = AuthSchema()
|
||||
user = await UserCRUD(auth, db).get(id=user_id)
|
||||
if not user:
|
||||
raise CustomException(msg="刷新token失败,用户不存在")
|
||||
if user.status == 1:
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
now = datetime.now()
|
||||
|
||||
# 延长会话信息 Redis TTL
|
||||
await RedisCURD(redis).expire(
|
||||
key=f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}",
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=False,
|
||||
exp=now + access_expires,
|
||||
),
|
||||
)
|
||||
|
||||
refresh_token_new = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=True,
|
||||
exp=now + refresh_expires,
|
||||
),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
|
||||
value=access_token,
|
||||
expire=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
|
||||
value=refresh_token_new,
|
||||
expire=int(refresh_expires.total_seconds()),
|
||||
)
|
||||
|
||||
return JWTOutSchema(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_new,
|
||||
token_type=settings.TOKEN_TYPE,
|
||||
expires_in=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def logout(redis: Redis, token: str) -> bool:
|
||||
"""退出登录"""
|
||||
payload: JWTPayloadSchema = decode_access_token(token=token)
|
||||
session_id = payload.sub
|
||||
|
||||
if not session_id:
|
||||
raise CustomException(msg="非法凭证,无法获取会话编号")
|
||||
|
||||
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}")
|
||||
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}")
|
||||
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
|
||||
|
||||
logger.info(f"用户退出登录成功,会话编号:{session_id}")
|
||||
|
||||
return True
|
||||
|
||||
class CaptchaService:
|
||||
"""验证码服务 — 滑块拖动模式"""
|
||||
|
||||
@staticmethod
|
||||
async def get_captcha(redis: Redis) -> CaptchaOutSchema:
|
||||
"""获取验证码(滑块模式:仅生成 key,无需算术图片)
|
||||
|
||||
未开启验证码时返回 enable=False 的空响应,前端据此隐藏滑块、直接登录,
|
||||
而非抛 500(否则登录页在 dev 等关闭验证码环境下会崩溃)。
|
||||
"""
|
||||
if not settings.CAPTCHA_ENABLE:
|
||||
return CaptchaOutSchema(
|
||||
enable=False,
|
||||
key="",
|
||||
img_base="",
|
||||
)
|
||||
|
||||
captcha_key = get_random_character()
|
||||
redis_key = f"{RedisInitKeyConfig.CAPTCHA_CODES.key}:{captcha_key}"
|
||||
# 存储滑块状态:pending(待验证)/ verified(已验证通过)
|
||||
await RedisCURD(redis).set(
|
||||
key=redis_key,
|
||||
value="pending",
|
||||
expire=settings.CAPTCHA_EXPIRE_SECONDS,
|
||||
)
|
||||
|
||||
return CaptchaOutSchema(
|
||||
enable=settings.CAPTCHA_ENABLE,
|
||||
key=CaptchaKey(captcha_key),
|
||||
img_base=CaptchaBase64(""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
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}"
|
||||
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).delete(redis_key)
|
||||
return True
|
||||
Reference in New Issue
Block a user